mirror of
https://github.com/Cacti/cacti.git
synced 2026-11-03 10:37:46 +09:00
* 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>
324 lines
11 KiB
PHP
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');
|
|
});
|