Files
cacti/tests/Unit/Security/Csrf/CactiCsrfRewriteTest.php
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

143 lines
5.5 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;
/*
* The rewriter is the contract third-party plugins depend on: they never opt
* in, they just get a hidden field and a patched XHR. These assertions pin
* the field name, the JS globals and the script tag, because changing any of
* them silently breaks every plugin that posts.
*/
function csrf_test_rewrite_guard() : CactiCsrfGuard {
$_SESSION = [];
$storage = new NativeSessionTokenStorage();
$manager = new CsrfTokenManager(new UriSafeTokenGenerator(), $storage);
$guard = new CactiCsrfGuard($manager, true);
$guard->setScriptUrl('/cacti/include/js/csrf.js');
return $guard;
}
test('a POST form gets the hidden token field', function () {
$guard = csrf_test_rewrite_guard();
$out = $guard->rewriteBuffer("<html><head></head><body><form method='post'></form></body></html>");
expect($out)->toContain("name='__csrf_magic'");
expect($out)->toMatch('#<form[^>]*>\s*<input#');
});
test('a GET form is left alone', function () {
$guard = csrf_test_rewrite_guard();
$out = $guard->rewriteBuffer("<html><head></head><body><form method='get'></form></body></html>");
/*
* The head script is injected unconditionally (it also serves pages
* whose only POST comes from AJAX, with no <form> at all), so it
* legitimately carries the field name. What must NOT happen is the
* hidden field landing inside the GET form itself.
*/
expect($out)->not->toContain("name='__csrf_magic'");
});
test('the JS globals and script tag are injected into head', function () {
$guard = csrf_test_rewrite_guard();
$out = $guard->rewriteBuffer('<html><head></head><body></body></html>');
expect($out)->toContain('var csrfMagicToken');
expect($out)->toContain('var csrfMagicName');
expect($out)->toContain('/cacti/include/js/csrf.js');
expect($out)->toContain('CsrfMagic.end();');
});
/*
* The JS globals sit inside a <script> string literal, a context
* htmlspecialchars() does not protect. json_encode() is what makes the
* token correct by construction there. token() re-mints a new random
* encoding on every call (BREACH mitigation), so the embedded value is
* pulled back out of the rendered output rather than compared against a
* second, necessarily different call to token().
*/
test('the JS globals are valid JavaScript string literals carrying the right values', function () {
$guard = csrf_test_rewrite_guard();
$out = $guard->rewriteBuffer('<html><head></head><body></body></html>');
preg_match('#var csrfMagicToken = "([^"]+)";#', $out, $matches);
expect($matches)->toHaveCount(2);
expect($guard->validate($matches[1]))->toBeTrue();
expect($out)->toContain('var csrfMagicName = "__csrf_magic";');
});
test('a non-HTML buffer is returned untouched', function () {
$guard = csrf_test_rewrite_guard();
$buffer = '{"error":"csrf_timeout"}';
expect($guard->rewriteBuffer($buffer))->toBe($buffer);
});
test('the frame breaker is not injected', function () {
$guard = csrf_test_rewrite_guard();
$out = $guard->rewriteBuffer('<html><head></head><body></body></html>');
expect($out)->not->toContain('top.location.href');
});
test('a nonce is emitted on injected scripts when one is set', function () {
$guard = csrf_test_rewrite_guard();
$guard->setNonce('abc123');
$out = $guard->rewriteBuffer('<html><head></head><body></body></html>');
expect($out)->toContain('nonce="abc123"');
});
test('no nonce attribute appears when none is set', function () {
$guard = csrf_test_rewrite_guard();
$out = $guard->rewriteBuffer('<html><head></head><body></body></html>');
expect($out)->not->toContain('nonce=');
});
test('a guard without a script URL only decorates POST forms', function () {
$_SESSION = [];
$manager = new CsrfTokenManager(new UriSafeTokenGenerator(), new NativeSessionTokenStorage());
$guard = new CactiCsrfGuard($manager, true);
$out = $guard->rewriteBuffer("<html><head></head><body><form method='post'></form></body></html>");
expect($out)->toContain("name='__csrf_magic'")
->and($out)->not->toContain('CsrfMagic.end()');
});
test('the end hook is appended when an HTML chunk has no closing body', function () {
$guard = csrf_test_rewrite_guard();
$out = $guard->rewriteBuffer('<html><head></head><main>content</main>');
expect($out)->toEndWith('<script type="text/javascript">CsrfMagic.end();</script>');
});
test('a disabled guard does not rewrite', function () {
$guard = new CactiCsrfGuard(null, false);
$buffer = "<html><head></head><body><form method='post'></form></body></html>";
expect($guard->rewriteBuffer($buffer))->toBe($buffer);
});