mirror of
https://github.com/Cacti/cacti.git
synced 2026-11-03 18:47:54 +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>
183 lines
6.4 KiB
PHP
183 lines
6.4 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 |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
require_once CACTI_PATH_LIBRARY . '/csrf.php';
|
|
|
|
use Symfony\Component\Security\Csrf\CsrfTokenManager;
|
|
use Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator;
|
|
use Symfony\Component\Security\Csrf\TokenStorage\NativeSessionTokenStorage;
|
|
|
|
/*
|
|
* During the upgrade window a browser may still hold a csrf-magic token minted
|
|
* before the swap. Those are accepted until they expire on the timestamp they
|
|
* carry, so the window closes itself with no configuration flag. These tests
|
|
* forge legacy tokens the way csrf-magic minted them.
|
|
*/
|
|
|
|
const CSRF_TEST_SECRET = 'a-known-test-secret';
|
|
|
|
/**
|
|
* Ensure a session exists before a test reads session_id().
|
|
*
|
|
* A legacy token binds to the session id, and NativeSessionTokenStorage only
|
|
* starts a session lazily on first access. Without this the id a test forges
|
|
* against is empty while the id validate() later sees is real, so this file
|
|
* passed only when some earlier file in the run had already started one.
|
|
*/
|
|
function csrf_test_start_session() : void {
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
}
|
|
|
|
function csrf_test_legacy_guard() : CactiCsrfGuard {
|
|
csrf_test_start_session();
|
|
|
|
$_SESSION = [];
|
|
|
|
$storage = new NativeSessionTokenStorage();
|
|
$manager = new CsrfTokenManager(new UriSafeTokenGenerator(), $storage);
|
|
|
|
return new CactiCsrfGuard($manager, true, CSRF_TEST_SECRET, 7200);
|
|
}
|
|
|
|
/**
|
|
* Rebuild a csrf-magic 'sid:' token exactly as the old library produced it.
|
|
*
|
|
* @param string $sessionId The session id the token was minted against.
|
|
* @param int $time The unix time embedded in the token.
|
|
* @param string $secret The shared secret.
|
|
*
|
|
* @return string The forged legacy token.
|
|
*/
|
|
function csrf_test_forge_legacy(string $sessionId, int $time, string $secret) : string {
|
|
$inner = hash_hmac('sha1', $time . ':' . $sessionId, $secret);
|
|
$outer = hash_hmac('sha1', $inner, $secret);
|
|
|
|
return 'sid:' . $outer . ',' . $time;
|
|
}
|
|
|
|
test('a legacy token inside the window validates', function () {
|
|
$guard = csrf_test_legacy_guard();
|
|
$token = csrf_test_forge_legacy(session_id(), time(), CSRF_TEST_SECRET);
|
|
|
|
expect($guard->validate($token))->toBeTrue();
|
|
});
|
|
|
|
test('a legacy token past the window is rejected', function () {
|
|
$guard = csrf_test_legacy_guard();
|
|
$token = csrf_test_forge_legacy(session_id(), time() - 7201, CSRF_TEST_SECRET);
|
|
|
|
expect($guard->validate($token))->toBeFalse();
|
|
});
|
|
|
|
test('a legacy token signed with the wrong secret is rejected', function () {
|
|
$guard = csrf_test_legacy_guard();
|
|
$token = csrf_test_forge_legacy(session_id(), time(), 'a-different-secret');
|
|
|
|
expect($guard->validate($token))->toBeFalse();
|
|
});
|
|
|
|
test('values with no colon never reach the legacy path', function () {
|
|
$guard = csrf_test_legacy_guard();
|
|
|
|
expect($guard->validate('nocolonhere'))->toBeFalse();
|
|
expect($guard->validate(''))->toBeFalse();
|
|
});
|
|
|
|
test('malformed legacy token bodies are rejected', function () {
|
|
$guard = csrf_test_legacy_guard();
|
|
|
|
expect($guard->validate('sid:missing-comma'))->toBeFalse()
|
|
->and($guard->validate('sid:hash,not-a-timestamp'))->toBeFalse();
|
|
});
|
|
|
|
test('legacy validation is off when no secret is configured', function () {
|
|
csrf_test_start_session();
|
|
|
|
$_SESSION = [];
|
|
|
|
$storage = new NativeSessionTokenStorage();
|
|
$manager = new CsrfTokenManager(new UriSafeTokenGenerator(), $storage);
|
|
$guard = new CactiCsrfGuard($manager, true, '', 7200);
|
|
|
|
$token = csrf_test_forge_legacy(session_id(), time(), CSRF_TEST_SECRET);
|
|
|
|
expect($guard->validate($token))->toBeFalse();
|
|
});
|
|
|
|
/*
|
|
* cli/refresh_csrf.php does not write a bare digest. It writes PHP source:
|
|
*
|
|
* fwrite($fh, '<?php $secret = "' . $new_secret . '";' . PHP_EOL);
|
|
*
|
|
* csrf-magic keyed its HMAC on the raw bytes of that file, PHP tags and
|
|
* trailing newline included. Any site that followed Cacti's own rotation
|
|
* advice has a secret in this shape, so a reader that normalises whitespace
|
|
* derives a different key and rejects every pre-upgrade token it was added to
|
|
* accept -- silently, because a rejected token is indistinguishable from an
|
|
* expired one.
|
|
*/
|
|
|
|
/**
|
|
* Write a secret file the way cli/refresh_csrf.php writes it.
|
|
*
|
|
* @param string $digest The hex digest to embed.
|
|
*
|
|
* @return string Path to the temporary file, which the caller must unlink.
|
|
*/
|
|
function csrf_test_write_rotated_secret(string $digest) : string {
|
|
$file = tempnam(sys_get_temp_dir(), 'cacti-csrf-secret-');
|
|
|
|
file_put_contents($file, '<?php $secret = "' . $digest . '";' . PHP_EOL);
|
|
|
|
return $file;
|
|
}
|
|
|
|
test('a secret rotated by refresh_csrf.php still validates a pre-upgrade token', function () {
|
|
$file = csrf_test_write_rotated_secret(str_repeat('ab', 20));
|
|
|
|
try {
|
|
$raw = (string) file_get_contents($file);
|
|
|
|
expect($raw)->toEndWith(PHP_EOL)
|
|
->and($raw)->not->toBe(trim($raw));
|
|
|
|
csrf_test_start_session();
|
|
|
|
$_SESSION = [];
|
|
|
|
$manager = new CsrfTokenManager(new UriSafeTokenGenerator(), new NativeSessionTokenStorage());
|
|
$token = csrf_test_forge_legacy(session_id(), time(), $raw);
|
|
|
|
$guard = new CactiCsrfGuard($manager, true, $raw, 7200);
|
|
expect($guard->validate($token))->toBeTrue();
|
|
|
|
// the same file read through trim() is a different key
|
|
$trimmed = new CactiCsrfGuard($manager, true, trim($raw), 7200);
|
|
expect($trimmed->validate($token))->toBeFalse();
|
|
} finally {
|
|
unlink($file);
|
|
}
|
|
});
|
|
|
|
test('neither secret reader normalises the bytes it reads', function () {
|
|
$src = file_get_contents(CACTI_PATH_INCLUDE . '/csrf.php');
|
|
|
|
expect($src)->toContain('file_get_contents($secret_file)')
|
|
->and($src)->toContain('(string) @file_get_contents($file)')
|
|
->and($src)->not->toContain('trim((string) file_get_contents($secret_file))')
|
|
->and($src)->not->toContain('trim((string) @file_get_contents($file))');
|
|
});
|