'jpg', 'audio' => 'mp3', 'videos' => 'mp4', 'documents' => 'pdf'] as $type => $extension) { if (is_dir($root . '/assets/' . $type . '-trash')) $targets[] = ['assets/' . $type . '-trash', $extension]; } return $targets; } /** Remove probe files left behind by an interrupted run (callers hold the check lock). */ function raijuSecuritySweepProbeFiles(string $root): void { foreach (array_merge(['assets/images'], array_column(raijuSecurityProbeTargets($root), 0)) as $folder) { foreach (glob($root . '/' . $folder . '/raiju-access-check-*') ?: [] as $file) { if (preg_match('/^raiju-access-check-[0-9a-f]{16}\.(txt|json|zip|jpg|mp3|mp4|pdf)$/', basename($file))) { @unlink($file); } } } } function raijuSecurityWriteProbe(string $file, string $extension): bool { $body = ['json' => "{}\n", 'zip' => "PK\x05\x06" . str_repeat("\0", 18)][$extension] ?? "raiju security check\n"; if (!is_dir(dirname($file)) || @file_put_contents($file, $body) === false) return false; // Readable like an uploaded file, even under a restrictive PHP umask. @chmod($file, 0644); return true; } /** * GET a URL and return only its final HTTP status (0 = no response). * TLS is verified first; a self-signed staging certificate falls back to an * unverified request, which is acceptable here: nothing secret is sent and * the response body is ignored. */ function raijuSecurityHttpStatus(string $url, bool &$insecure, int $timeout = 5): array { $response = raijuSecurityHttpFetch($url, $insecure, $timeout); return ['status' => $response['status'], 'finalUrl' => $response['finalUrl'], 'error' => $response['error']]; } /** * Like raijuSecurityHttpStatus(), but also returns the response headers of the final response * (lower-case names, repeated headers joined with ", ") and the body, cut off after $maxBody bytes. * The user agent is a bot name so that the statistics count these requests as bots, not visitors. * Never sends cookies, so the answer is what an anonymous visitor or crawler gets. */ function raijuSecurityHttpFetch(string $url, bool &$insecure, int $timeout = 5, int $maxBody = 0): array { for ($attempt = 0; $attempt < 2; $attempt++) { $headers = []; $body = ''; $truncated = false; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_CONNECTTIMEOUT => min(3, $timeout), CURLOPT_TIMEOUT => $timeout, CURLOPT_SSL_VERIFYPEER => !$insecure, CURLOPT_SSL_VERIFYHOST => $insecure ? 0 : 2, CURLOPT_USERAGENT => 'Raiju-Security-Check-Bot/' . raijuVersion(), CURLOPT_HTTPHEADER => ['Cache-Control: no-cache'], // Headers of earlier redirect hops are dropped; only the final response is kept. CURLOPT_HEADERFUNCTION => function ($handle, string $line) use (&$headers): int { if (stripos($line, 'HTTP/') === 0) { $headers = []; } elseif (str_contains($line, ':')) { [$name, $value] = explode(':', $line, 2); $name = strtolower(trim($name)); $headers[$name] = isset($headers[$name]) ? $headers[$name] . ', ' . trim($value) : trim($value); } return strlen($line); }, ]); if ($maxBody > 0) { curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($handle, string $chunk) use (&$body, &$truncated, $maxBody): int { $body .= substr($chunk, 0, max(0, $maxBody - strlen($body))); if (strlen($body) >= $maxBody) { $truncated = true; return 0; // stops the transfer; handled below as a complete answer } return strlen($chunk); }); } if (defined('CURLOPT_PROTOCOLS')) curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); if (defined('CURLOPT_REDIR_PROTOCOLS')) curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $finalUrl = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); unset($ch); if ($truncated && $errno === 23) $errno = 0; if ($errno && !$insecure && in_array($errno, [35, 51, 58, 60, 77, 83], true)) { $insecure = true; continue; } return [ 'status' => $errno ? 0 : $status, 'finalUrl' => $finalUrl, 'error' => $errno ? $error : '', 'headers' => $headers, 'body' => $body, ]; } return ['status' => 0, 'finalUrl' => $url, 'error' => 'TLS', 'headers' => [], 'body' => '']; } function raijuSecurityProbeState(int $status): string { if ($status >= 200 && $status < 300) return 'exposed'; return in_array($status, [403, 404, 410], true) ? 'protected' : 'unknown'; } /** * Create probe files, request them through the public URL and remove them again. * $plainBaseUrl is the http:// counterpart of an https:// site; by default only * addresses on standard ports have one. */ function raijuSecurityRunProbe(string $root, string $baseUrl, ?string $plainBaseUrl = null): array { $result = ['version' => 1, 'baseUrl' => $baseUrl, 'checkedAt' => date('c'), 'checkedTs' => time(), 'state' => 'unknown', 'reason' => '', 'detail' => '', 'probes' => [], 'http' => 'skipped']; if (!function_exists('curl_init')) { $result['reason'] = 'no_curl'; return $result; } $name = 'raiju-access-check-' . bin2hex(random_bytes(8)); $created = []; try { raijuSecuritySweepProbeFiles($root); $control = 'css/style.css'; if (raijuSecurityWriteProbe($root . '/assets/images/' . $name . '.txt', 'txt')) { $created[] = $root . '/assets/images/' . $name . '.txt'; $control = 'assets/images/' . $name . '.txt'; } $urls = []; foreach (raijuSecurityProbeTargets($root) as [$folder, $extension]) { $urls[] = $folder . '/' . $name . '.' . $extension; $written = raijuSecurityWriteProbe($root . '/' . end($urls), $extension); if ($written) $created[] = $root . '/' . end($urls); $result['probes'][] = ['path' => $folder . '/', 'status' => 0, 'state' => $written ? 'pending' : 'skipped']; } $insecure = false; $response = raijuSecurityHttpStatus($baseUrl . '/' . $control, $insecure); if ($response['status'] < 200 || $response['status'] >= 300) { $result['reason'] = $response['status'] === 401 ? 'auth' : 'unreachable'; $result['detail'] = $response['status'] ? 'HTTP ' . $response['status'] : $response['error']; return $result; } foreach ($result['probes'] as $index => $probe) { if ($probe['state'] === 'skipped') continue; $status = raijuSecurityHttpStatus($baseUrl . '/' . $urls[$index], $insecure)['status']; $result['probes'][$index] = ['path' => $probe['path'], 'status' => $status, 'state' => raijuSecurityProbeState($status)]; } $states = array_column($result['probes'], 'state'); if (in_array('exposed', $states, true)) { $result['state'] = 'exposed'; } elseif (in_array('protected', $states, true) && !in_array('unknown', $states, true)) { $result['state'] = 'protected'; } else { $unexpected = array_values(array_filter($result['probes'], fn($probe) => $probe['state'] === 'unknown')); $result['reason'] = $unexpected ? 'status' : 'not_writable'; $result['detail'] = $unexpected ? 'HTTP ' . $unexpected[0]['status'] : ''; } // Plain http:// should end on https://. if ($plainBaseUrl === null) { $parts = parse_url($baseUrl); $plainBaseUrl = ($parts['scheme'] ?? '') === 'https' && !isset($parts['port']) ? 'http://' . $parts['host'] . ($parts['path'] ?? '') : ''; } if ($plainBaseUrl !== '') { $plain = raijuSecurityHttpStatus($plainBaseUrl . '/' . $control, $insecure, 3); $result['http'] = str_starts_with(strtolower($plain['finalUrl']), 'https://') ? 'redirect' : ($plain['status'] >= 200 && $plain['status'] < 300 ? 'open' : 'closed'); } return $result; } finally { foreach ($created as $file) @unlink($file); } } function raijuSecurityCachePath(): string { return dirname(__DIR__) . '/content/security-check.json'; } /** Re-check soon while something is wrong; a protected result stays valid for 12 hours. */ function raijuSecurityCacheTtl(string $state): int { return ['protected' => 43200, 'exposed' => 600][$state] ?? 3600; } /** Cached probe result for this address, refreshed when $run allows it. */ function raijuSecurityProbeResult(string $baseUrl, bool $local, bool $run, bool $force): array { $path = raijuSecurityCachePath(); $cached = is_file($path) ? json_decode((string)file_get_contents($path), true) : null; // A restored backup or a moved site must not inherit another address's result. if (!is_array($cached) || ($cached['baseUrl'] ?? '') !== $baseUrl || !isset($cached['checkedTs'], $cached['state'], $cached['probes'])) { $cached = null; } $stale = !$cached || time() - (int)$cached['checkedTs'] > raijuSecurityCacheTtl((string)$cached['state']); $running = false; if ($run && ($force || ($stale && !$local))) { $lock = @fopen($path . '.lock', 'c'); if ($lock === false || flock($lock, LOCK_EX | LOCK_NB)) { try { $cached = raijuSecurityRunProbe(dirname(__DIR__), $baseUrl); raijuJsonAtomicWrite($path, $cached); $stale = false; } finally { if ($lock) flock($lock, LOCK_UN); } } else { $running = true; } if ($lock) fclose($lock); } return ['result' => $cached, 'stale' => $stale, 'running' => $running]; } /** Items "folders" and "http" come from the same (cached) network probe. */ function raijuSecurityProbeItems(string $baseUrl, bool $local, bool $devServer, string $scheme, bool $run, bool $force): array { $folders = ['id' => 'folders', 'canRun' => !$devServer && $baseUrl !== '', 'url' => $baseUrl]; $result = null; if ($devServer) { // The single-threaded development server cannot request itself; router.php blocks these folders. $folders += ['state' => 'skipped', 'reason' => 'dev_server']; } elseif ($baseUrl === '') { $folders += ['state' => 'unknown', 'reason' => 'host']; } else { $probe = raijuSecurityProbeResult($baseUrl, $local, $run, $force); $result = $probe['result']; $folders += ['stale' => $probe['stale'], 'running' => $probe['running']]; if (!$result) { $folders += $local ? ['state' => 'skipped', 'reason' => 'local'] : ['state' => 'pending']; } else { $exposed = array_values(array_column(array_filter($result['probes'], fn($probe) => $probe['state'] === 'exposed'), 'path')); // Accounts, credentials, submissions and backups are critical; deleted media alone is a warning. $critical = array_intersect($exposed, ['content/', 'backups/']) !== []; $folders += [ 'state' => ['exposed' => $critical ? 'critical' : 'warning', 'protected' => 'ok'][$result['state']] ?? 'unknown', 'reason' => $result['reason'], 'detail' => $result['detail'], 'checkedAt' => $result['checkedAt'], 'probes' => $result['probes'], 'exposed' => $exposed, ]; if ($exposed) $folders['rules'] = raijuSecurityNginxRules(raijuSecurityBasePath()); } } $mode = (string)($result['http'] ?? 'skipped'); if ($devServer || $scheme !== 'https') { $http = ['state' => 'skipped', 'reason' => $devServer ? 'dev_server' : 'scheme']; } elseif (!$result) { $http = array_intersect_key($folders, ['state' => true, 'reason' => true]); } elseif ($mode === 'skipped') { $http = $result['state'] === 'unknown' ? ['state' => 'unknown'] : ['state' => 'skipped', 'reason' => 'port']; } else { $http = ['state' => $mode === 'open' ? 'warning' : 'ok', 'mode' => $mode]; } return [$folders, ['id' => 'http'] + $http]; } /** Security findings for the system status. $run allows the network probe. */ function raijuSecurityStatus(string $scheme = '', bool $run = false, bool $force = false): array { $scheme = in_array($scheme, ['http', 'https'], true) ? $scheme : (raijuSecurityServerHttps() ? 'https' : 'http'); $local = raijuSecurityIsLocalHost((string)($_SERVER['HTTP_HOST'] ?? '')); $devServer = PHP_SAPI === 'cli-server'; $items = raijuSecurityProbeItems(raijuSecurityBaseUrl($scheme), $local, $devServer, $scheme, $run, $force); $remote = (string)($_SERVER['REMOTE_ADDR'] ?? ''); // A local reverse proxy that hides the visitor address makes every request look alike. $proxy = $remote !== '' && (raijuSecurityIsLoopbackIp($remote) || $remote === (string)($_SERVER['SERVER_ADDR'] ?? '')); // admin/api/bootstrap.php records the value before switching error display off for JSON. $displayErrors = defined('RAIJU_INITIAL_DISPLAY_ERRORS') ? RAIJU_INITIAL_DISPLAY_ERRORS : (string)ini_get('display_errors'); $devLogin = !defined('RAIJU_DEV_LOGIN') || RAIJU_DEV_LOGIN === true; if ($local) { foreach (['https', 'cookie', 'client_ip', 'errors', 'dev_login'] as $id) { $items[] = ['id' => $id, 'state' => 'skipped', 'reason' => 'local']; } } else { $items[] = ['id' => 'https', 'state' => $scheme === 'https' ? 'ok' : 'warning']; $items[] = $scheme === 'https' ? ['id' => 'cookie', 'state' => raijuSecurityServerHttps() ? 'ok' : 'warning'] : ['id' => 'cookie', 'state' => 'skipped', 'reason' => 'scheme']; $items[] = ['id' => 'client_ip', 'state' => $proxy ? 'warning' : 'ok', 'ip' => $proxy ? $remote : '']; $items[] = ['id' => 'errors', 'state' => strtolower($displayErrors) === 'stdout' || filter_var($displayErrors, FILTER_VALIDATE_BOOLEAN) ? 'warning' : 'ok']; $items[] = ['id' => 'dev_login', 'state' => !$devLogin ? 'ok' : (raijuSecurityIsLoopbackIp($remote) ? 'warning' : 'note')]; } // End of upstream security support, https://www.php.net/supported-versions.php (extend with each PHP release). $until = ['8.1' => '2025-12-31', '8.2' => '2026-12-31', '8.3' => '2027-12-31', '8.4' => '2028-12-31', '8.5' => '2029-12-31'][PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION] ?? ''; $items[] = ['id' => 'php', 'state' => $until !== '' && date('Y-m-d') > $until ? 'warning' : 'ok', 'version' => PHP_VERSION, 'until' => $until]; return ['local' => $local, 'devServer' => $devServer, 'items' => $items]; }