Files
Thomas VincentandTheWitness cb49f31ff9 Replace csrf-magic with maintained CSRF protection (#7744)
* build: add symfony/security-csrf

Backs the CSRF token core that replaces the vendored csrf-magic fork.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: fix security-csrf package path assertion

symfony/security-csrf ships CsrfTokenManager.php at the package root, not under src/.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* refactor: register the composer autoloader before the CSRF include

CSRF validation ran before the autoloader existed, which blocks any class-based guard.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* feat(csrf): add CactiCsrfGuard over symfony/security-csrf

Issues and validates session-scoped tokens; not yet wired into the request path.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* feat(csrf): accept pre-upgrade csrf-magic tokens during the grace window

Legacy tokens carry their own expiry, so the fallback closes itself without a config flag.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* feat(csrf): port the output buffer rewriter to CactiCsrfGuard

Keeps the hidden field name and JS globals that third-party plugins rely on.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(csrf): use json_encode for the JS script context in rewriteBuffer

htmlspecialchars() protects the HTML attribute context but not the
<script> string literal, which relied on Symfony's token generator
never emitting a quote rather than being safe by construction.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* refactor(csrf): move the XHR shim to include/js as Cacti-owned code

It has been maintained by the Cacti Group since 2020 and is not an upstream package.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* refactor(csrf): retire the vendored csrf-magic fork

The procedural csrf_* surface stays as a deprecated shim so plugins keep working.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* feat(csrf): validate requests through CactiCsrfGuard

csrf-magic self-invoked from its own file; the guard is called explicitly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(install): stop gating the permission step on the csrf secret

Retiring csrf-magic removed the directory its default path lives in, and setStep() clamps to the first errored step, so no fresh install could advance.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(csrf): key the legacy fallback on the raw secret bytes

refresh_csrf.php writes PHP source with a trailing newline, and trimming that yields a different HMAC key than csrf-magic used.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(csrf): log when the guard degrades to disabled in a web context

The installer is the only web path that can reach it, and failing open in silence is not something csrf-magic could do.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs: changelog for the CSRF library replacement

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(csrf): mint the token before the session write callback closes it

csrf_startup() minted lazily from the output buffer callback, which runs after session_write_close()'s shutdown-function registration, so a token minted there on a legacy-fallback POST was silently dropped.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(csrf): make refresh_csrf.php a no-op

Its default secret path no longer ships and live tokens are session-bound, not HMACs over the secret, so rotation neither worked nor did anything meaningful; running it during the upgrade grace window used to delete the legacy secret and kill validation for pre-upgrade tokens outright.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(csrf): fall back to the old default secret path when reading it

csrf-magic tried its own directory as a second candidate regardless of $path_csrf_secret; the new reader only tried the configured path, so a site pointing $path_csrf_secret at a file that was never created lost the legacy-token grace window entirely.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs(csrf): point the JS header at its BSD license sidecar

The header reduces the original BSD terms to prose and the actual text sits in an unreferenced csrf.js.LICENSE, so add a pointer to it.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(csrf): restore csrf_check()'s bool return and $fatal contract

The shim ignored $fatal and always returned void, so audit/audit.php's `!csrf_check(false)` evaluated `!null` (always true) and denied every request instead of getting a real pass/fail result back.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs: reference the pull request number in the changelog entry

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: resolve includes through Cacti path constants

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(csrf): enforce full coverage

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* Harden CSRF fallback handling

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(csrf): assert the component stays out of the index

Committing it would leave a second copy to drift against the lock.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: align CSRF checks with PHP 8.2

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs: normalize the CSRF changelog entry

* Fix CHANGELOG entry ordering

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(csrf): support constrained test environments

* fix(csrf): narrow nullable guard state

* test: place CSRF coverage in security domain

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* security(test): match disabled exec exactly

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: run CSRF coverage on PHP 8.3

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-09-18 00:16:39 -04:00

324 lines
11 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* The procedural csrf_* functions are a deprecated compatibility surface.
* Third-party plugins call them and the installer calls them, so they must
* keep resolving and keep delegating to the guard until phase 2 retires them.
*/
$root = dirname(__DIR__, 4);
test('the vendored csrf-magic fork is gone', function () use ($root) {
expect(is_dir($root . '/include/vendor/csrf'))->toBeFalse();
});
test('include/csrf.php no longer loads the fork', function () use ($root) {
$src = file_get_contents($root . '/include/csrf.php');
expect($src)->not->toContain('csrf-magic.php');
expect($src)->not->toContain('csrf-conf.php');
});
test('the deprecated procedural surface still exists', function () use ($root) {
$src = file_get_contents($root . '/include/csrf.php');
foreach (['csrf_guard', 'csrf_get_tokens', 'csrf_check', 'csrf_check_tokens', 'csrf_conf', 'csrf_startup', 'csrf_error_callback', 'csrf_get_secret', 'csrf_generate_secret', 'csrf_writable', 'csrf_log'] as $fn) {
expect($src)->toContain('function ' . $fn . '(');
}
});
test('the installer error branch is preserved verbatim', function () use ($root) {
$src = file_get_contents($root . '/include/csrf.php');
expect($src)->toContain('csrf_timeout');
expect($src)->toContain('IN_CACTI_INSTALL');
expect($src)->toContain('session_regenerate_id(true)');
});
/**
* Collect the csrf_* functions a file actually calls.
*
* Tokenising rather than matching text keeps prose out of the result;
* cli/refresh_csrf.php names csrf_secret() in its help output, which a regex
* over the raw source reports as a call to a function that never existed.
*
* @param string $file The PHP file to scan.
*
* @return array<int, string> The called function names, without duplicates.
*/
function csrf_facade_called_functions(string $file) : array {
$tokens = token_get_all((string) file_get_contents($file));
$found = [];
foreach ($tokens as $index => $token) {
if (!is_array($token) || $token[0] !== T_STRING || strpos($token[1], 'csrf_') !== 0) {
continue;
}
// a declaration, a method call or a static call is not a plain call
for ($back = $index - 1; $back >= 0; $back--) {
$previous = $tokens[$back];
if (is_array($previous) && ($previous[0] === T_WHITESPACE || $previous[0] === T_COMMENT || $previous[0] === T_DOC_COMMENT)) {
continue;
}
if (is_array($previous) && in_array($previous[0], [T_FUNCTION, T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_NULLSAFE_OBJECT_OPERATOR], true)) {
continue 2;
}
break;
}
for ($ahead = $index + 1; $ahead < count($tokens); $ahead++) {
$next = $tokens[$ahead];
if (is_array($next) && $next[0] === T_WHITESPACE) {
continue;
}
if ($next === '(') {
$found[] = $token[1];
}
break;
}
}
return array_values(array_unique($found));
}
/*
* csrf-magic defined helpers that outlive it: the installer writes the secret
* and the refresh CLI rotates it. Deleting the fork without porting these
* fatals a fresh install.
*/
test('every surviving csrf_ caller still resolves', function () use ($root) {
$callers = [
$root . '/install/functions.php',
$root . '/cli/refresh_csrf.php',
$root . '/pollers.php',
$root . '/include/global_session.php',
$root . '/install/step_json.php',
$root . '/include/global.php',
];
$facade = file_get_contents($root . '/include/csrf.php');
$lib = file_get_contents($root . '/lib/csrf.php');
$seen = 0;
foreach ($callers as $caller) {
foreach (csrf_facade_called_functions($caller) as $fn) {
$seen++;
expect($facade . $lib)->toContain('function ' . $fn . '(');
}
}
// a scanner that silently matches nothing would pass every assertion above
expect($seen)->toBeGreaterThan(0);
});
test('the script url points at the moved asset', function () use ($root) {
$src = file_get_contents($root . '/include/csrf.php');
expect($src)->toContain('include/js/csrf.js');
expect($src)->not->toContain('include/vendor/csrf/csrf-magic.js');
});
/*
* Only an explicit installer request may degrade while Composer is still
* populating include/vendor. A deployed web request that cannot autoload the
* Symfony manager must stop before it serves a request without CSRF checks.
*/
test('a deployed web request fails closed when Symfony CSRF is unavailable', function () {
$result = csrf_facade_run_php_fixture('CsrfGuardAvailabilityProbe.php', 'deployed');
expect($result['exitCode'])->toBe(1)
->and($result['stdout'])->toContain('FATAL: CSRF protection is unavailable')
->and($result['stderr'])->toContain('log:FATAL: CSRF protection is unavailable')
->toContain('status:500')
->not->toContain('enabled:');
});
test('the installer may temporarily disable the guard before Composer is available', function () {
$result = csrf_facade_run_php_fixture('CsrfGuardAvailabilityProbe.php', 'installer');
expect($result['exitCode'])->toBe(0)
->and($result['stdout'])->toBe('')
->and($result['stderr'])->toContain('during installation')
->toContain('enabled:false');
});
test('the installer creates the legacy secret without world-readable permissions', function () {
$result = csrf_facade_run_php_fixture('InstallerCsrfSecretPermissionsProbe.php');
expect($result['exitCode'])->toBe(0)
->and($result['stderr'])->toContain('created:true')
->toContain('mode:640');
});
test('a legacy secret directory resolves to csrf-secret.php', function () {
$result = csrf_facade_run_php_fixture('CsrfSecretDirectoryProbe.php');
expect($result['exitCode'])->toBe(0)
->and($result['stderr'])->toContain('path:')
->toContain('/csrf-secret.php')
->toContain('reader:true')
->toContain('legacy:true');
});
/*
* Retiring the fork took include/vendor/csrf/ with it, and that is where
* path_csrf_secret has always defaulted. The installer used to demand the path
* be writable: getPermissions() raised a STEP_PERMISSION_CHECK error, and
* setStep() clamps the current step down to the first errored step, so a fresh
* install could not advance past a directory the user has no way to create.
*
* The default is deliberately left pointing at the old location. Upgrades read
* it for the grace window, and moving it would strand every existing install.
*/
test('the installer does not gate on the retired secret path', function () use ($root) {
$installer = file_get_contents($root . '/lib/installer.php');
expect($installer)->not->toContain("\$install_paths['csrf']")
->and($installer)->not->toContain("if (\$name == 'csrf')");
});
test('the installer skips the secret when its directory is absent', function () use ($root) {
$installer = file_get_contents($root . '/lib/installer.php');
$start = strpos($installer, 'private function setCSRFSecret(');
expect($start)->not->toBeFalse();
$end = strpos($installer, "\n\t/**", $start + 1);
$body = substr($installer, $start, ($end === false ? strlen($installer) : $end) - $start);
$guard = strpos($body, 'is_dir(dirname($file))');
$create = strpos($body, 'install_create_csrf_secret(');
expect($guard)->not->toBeFalse()
->and($create)->not->toBeFalse()
->and($guard)->toBeLessThan($create);
});
test('the default secret path is unchanged so upgrades still find it', function () use ($root) {
$global = file_get_contents($root . '/include/global.php');
expect($global)->toContain("CACTI_PATH_INCLUDE . '/vendor/csrf/csrf-secret.php'");
});
/*
* csrf_check()'s $fatal contract: true for a non-POST request, the
* validation result for a POST when $fatal is false, and a failed POST
* routed to csrf_error_callback() (which exits) when $fatal is true. The
* audit plugin calls `!csrf_check(false)` as an admin guard; a void return
* makes `!null` always true, which denies every request regardless of the
* token. See tests/fixtures/CsrfCheckContractProbe.php.
*
* The default-fatal failure path exits, so it cannot run inside the Pest
* process without killing the test run. Each mode runs the probe in its own
* PHP subprocess instead, which is the only way to see exit() actually fire
* rather than inferring it from source text.
*/
/**
* Run tests/fixtures/CsrfCheckContractProbe.php in a fresh PHP process.
*
* @param string $mode One of the probe's argv[1] modes.
*
* @return array{exitCode: int, stderr: string} The process exit code and
* everything it wrote to stderr.
*/
function csrf_facade_run_contract_probe(string $mode) : array {
return csrf_facade_run_php_fixture('CsrfCheckContractProbe.php', $mode);
}
/**
* Run a tests/fixtures PHP probe in a fresh process.
*
* @param string $fixture Fixture filename.
* @param string $mode Optional argv[1] mode.
*
* @return array{exitCode: int, stdout: string, stderr: string}
*/
function csrf_facade_run_php_fixture(string $fixture, string $mode = '') : array {
$root = dirname(__DIR__, 4);
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open(
[PHP_BINARY, $root . '/tests/fixtures/' . $fixture, $mode],
$descriptors,
$pipes,
$root
);
if (!is_resource($process)) {
throw new RuntimeException('Unable to start the CSRF fixture ' . $fixture);
}
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'stderr' => $stderr];
}
test('csrf_check() returns true on a non-POST request', function () {
$result = csrf_facade_run_contract_probe('get');
expect($result['exitCode'])->toBe(0)
->and($result['stderr'])->toContain('before')
->toContain('result:true')
->toContain('after');
});
test('csrf_check(false) returns false on a bad POST token without exiting', function () {
$result = csrf_facade_run_contract_probe('nonfatal-fail');
expect($result['exitCode'])->toBe(0)
->and($result['stderr'])->toContain('before')
->toContain('result:false')
->toContain('after');
});
/*
* "after" and "result:" only print once csrf_check() returns control to the
* probe. On the default-fatal path a failed check must never return, so
* their absence is the assertion; seeing either would mean the fix
* regressed to returning instead of exiting. exitCode 0 (not 255, and not a
* signal) confirms the process ended via exit() and not a PHP fatal error.
*/
test('csrf_check() with the default $fatal still exits on a failed POST', function () {
$result = csrf_facade_run_contract_probe('fatal-fail');
expect($result['exitCode'])->toBe(0)
->and($result['stderr'])->toContain('before')
->not->toContain('after')
->not->toContain('result:')
->not->toContain('Fatal error');
});