setScriptUrl(CACTI_PATH_URL . 'include/js/csrf.js'); return $guard; } /** * Register the output rewriter and validate the current request. * * Replaces csrf-magic's self-executing bootstrap, which ran from the bottom of * the library file as a side effect of including it. */ function csrf_startup() : void { $guard = csrf_guard(); if (!$guard->isEnabled()) { return; } /* include/session.php registers session_write_close() as a shutdown function, and shutdown functions run before output buffers flush. If the token were minted lazily from inside the output buffer callback below, the first mint on a POST that reaches csrf_check() via the legacy fallback would write $_SESSION after the session already closed, and the token rendered into the page would never validate. Mint it now, while the session is still open. */ $guard->token(); ob_start(function ($buffer) use ($guard) { return $guard->rewriteBuffer($buffer); }); csrf_check(); } /** * @return string A token for the current session, or an empty string when the * guard is disabled. */ function csrf_get_tokens() : string { csrf_deprecated(__FUNCTION__); return csrf_guard()->token(); } /** * Validate a POST request. * * csrf-magic returned a bool, and plugins such as the bundled audit plugin * branch on it directly (`!csrf_check(false)`). A void return silently * breaks that caller: `!null` is always true, so the check would appear to * fail on every request. $fatal has the same meaning it always did: true * routes a failed check to csrf_error_callback(), which exits; false hands * the result back to the caller instead. * * @param bool $fatal Exit via csrf_error_callback() on failure when true; * otherwise return false and let the caller decide. * * @return bool True for a non-POST request or a validated POST; false for a * POST that failed validation and $fatal is false. */ function csrf_check(bool $fatal = true) : bool { if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') { return true; } if (csrf_is_saml_callback()) { return true; } $submitted = filter_input(INPUT_POST, CactiCsrfGuard::INPUT_NAME, FILTER_UNSAFE_RAW); $submitted = (is_string($submitted) ? $submitted : ''); if (csrf_guard()->validate($submitted)) { return true; } if ($fatal) { csrf_error_callback(); } return false; } /** * SAML ACS requests carry an IdP assertion rather than a Cacti form token. * Only the dedicated controller may request this exemption, and only for an * enabled SAML provider while Login Providers is the active auth method. * The provider still requires its saved request ID and validates signatures. */ function csrf_is_saml_callback() : bool { if (!defined('CACTI_SAML_POST_CALLBACK') || ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { return false; } if ((int) read_config_option('auth_method') !== AUTH_METHOD_PROVIDERS) { return false; } try { $provider = \Cacti\Auth\LoginProviderFactory::fromRealm((int) gfrv('realm')); return $provider instanceof \Cacti\Auth\SamlLoginProvider; } catch (\Throwable $error) { return false; } } /** * @param mixed $tokens One token, or a ';' separated list as csrf-magic sent. * * @return bool True when any supplied token validates. */ function csrf_check_tokens(mixed $tokens) : bool { csrf_deprecated(__FUNCTION__); if (is_string($tokens)) { $tokens = explode(';', $tokens); } foreach ((array) $tokens as $token) { if (csrf_guard()->validate((string) $token)) { return true; } } return false; } /** * Accept and discard a csrf-magic configuration call. * * The guard is configured through its constructor. This exists so plugins * that call csrf_conf() do not fatal. * * @param string $key The csrf-magic option name. * @param mixed $value The value, ignored. */ function csrf_conf(string $key, mixed $value) : void { csrf_deprecated(__FUNCTION__); } /** * Log one deprecation notice per function per request. * * Silent at default verbosity. A plugin author opts in by raising the log * level; logging unconditionally would flood cacti.log. * * @param string $function The deprecated function that was called. */ function csrf_deprecated(string $function) : void { static $seen = []; if (isset($seen[$function])) { return; } $seen[$function] = true; cacti_log($function . '() is deprecated and will be removed; use csrf_guard() instead', false, 'CSRF', POLLER_VERBOSITY_DEBUG); } /** * Resolve the legacy secret configuration to a file. * * Older installer code accepted a directory and appended csrf-secret.php, * even though config.php.dist documents a full file path. Preserve that * behavior during the legacy-token grace window. * * @param string $path A configured file or directory path. * * @return string The resolved secret file path. */ function csrf_secret_file_path(string $path) : string { if ($path !== '' && is_dir($path)) { return rtrim($path, '/\\') . '/csrf-secret.php'; } return $path; } /** * Read the csrf-magic secret, or mint and persist one if none exists. * * Retained because install/functions.php writes this value during a fresh * install and the legacy token fallback verifies against it. Removed in * phase 2 along with the rest of the secret plumbing. * * @return string The secret, or an empty string when none can be established. */ function csrf_get_secret() : string { static $secret = null; if ($secret !== null) { return $secret; } $file = csrf_secret_file_path(defined('CACTI_CSRF_SECRET') ? CACTI_CSRF_SECRET : ''); if ($file != '' && file_exists($file)) { // the raw bytes are the key; see the note in csrf_guard() $secret = (string) @file_get_contents($file); if ($secret !== '') { return $secret; } } $secret = csrf_generate_secret(); if ($file != '' && csrf_writable($file)) { $old_umask = umask(0027); $fh = fopen($file, 'w'); if ($fh !== false) { fwrite($fh, $secret); fclose($fh); } umask($old_umask); } return $secret; } /** * Generate a new secret. * * csrf-magic built this from mt_rand(), which is not a cryptographic source. * This uses random_bytes(). * * @return string A 64 character hex secret. */ function csrf_generate_secret() : string { return hash('sha256', random_bytes(32)); } /** * @param string $path The path to test. * * @return bool True when $path can be written without leaving a file behind. */ function csrf_writable(string $path) : bool { if ($path === '') { return false; } if (substr($path, -1) === '/') { return csrf_writable($path . uniqid((string) random_int(0, PHP_INT_MAX)) . '.tmp'); } if (file_exists($path)) { $fh = @fopen($path, 'a'); if ($fh === false) { return false; } fclose($fh); return true; } $fh = @fopen($path, 'w'); if ($fh === false) { return false; } fclose($fh); unlink($path); return true; } /** * Record a CSRF diagnostic. * * csrf-magic wrote these to its own log file. They go to cacti.log now, at a * verbosity that is silent by default. * * @param string $function The reporting function. * @param string $message What happened. */ function csrf_log(string $function, string $message) : void { cacti_log($function . '(): ' . $message, false, 'CSRF', POLLER_VERBOSITY_DEBUG); } /** * Handle a request that failed CSRF validation. * * The installer runs its steps over JSON, so a plain redirect leaves it stuck * on a dead XHR. That branch answers with a scoped payload carrying a fresh * token instead, which install.js retries against once. See issue #7343. */ function csrf_error_callback() : void { // Invalidate the failed session id instead of leaving it usable until GC. session_regenerate_id(true); if (defined('IN_CACTI_INSTALL') && isset($GLOBALS['auth_json']) && $GLOBALS['auth_json'] === true && !empty($GLOBALS['is_request_ajax'])) { $response = json_encode( [ 'error' => 'csrf_timeout', 'title' => __('Session Expired'), 'message' => __('Your installer session expired due to inactivity. Reload the installer and try again.'), 'csrfMagicToken' => csrf_get_tokens(), ], JSON_INVALID_UTF8_SUBSTITUTE ); if ($response === false) { $response = '{"error":"csrf_timeout","title":"Session Expired","message":"Reload the installer and try again."}'; } ob_end_clean(); http_response_code(403); header('Content-Type: application/json'); header('Cache-Control: no-store'); header('X-Content-Type-Options: nosniff'); header('Content-Length: ' . strlen($response)); print $response; csrf_log(__FUNCTION__, 'Timeout, returning JSON response for the installer'); exit; } raise_message('csrf_timeout'); ob_end_clean(); // sanitize_uri()'s guard models the WHATWG C0/leading-slash normalisation // rule but not Chromium's separate stripping of embedded tabs, so a // target like "/%09/evil.example" still reaches the browser as a // same-origin-looking "//evil.example" once Chromium collapses the tab. // validate_redirect_url() resolves the URL with parse_url() and compares // host/port against the server-configured values instead of pattern // matching known-bad prefixes, so it is not bypassable this way. $redirect_target = validate_redirect_url($_SERVER['REQUEST_URI'] ?? '', 'index.php'); header('Location: ' . $redirect_target); csrf_log(__FUNCTION__, 'Timeout, redirecting to ' . $redirect_target); exit; } /** * csrf_require_post - require a CSRF-protected POST for a state-changing action. * * State-changing actions reached through a direct action= URL must arrive by * POST so they cannot be triggered cross-site with a GET (for example an * tag). Reject invalid POST tokens before the action can change state. * * @return void */ function csrf_require_post() : void { if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') { header('Allow: POST'); http_response_code(405); exit; } if (!csrf_check(false)) { http_response_code(403); exit; } }