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

262 lines
9.3 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. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* CSRF token issuance and validation for Cacti.
*
* Replaces the token core of the vendored csrf-magic fork. Tokens are random
* values held in $_SESSION by Symfony rather than HMACs over a secret file.
* Phase 1 mints every token under a single intention, reproducing csrf-magic's
* "one token, valid anywhere" behaviour so existing forms and third-party
* plugins keep working unchanged.
*/
final class CactiCsrfGuard {
/**
* The single token scope used in phase 1. Per-form intentions arrive in
* phase 2; until then every token is minted and checked under this id.
*/
public const INTENTION_GLOBAL = 'cacti';
/**
* The POST field name. Third-party plugins read and write this name, so
* it is a compatibility contract and must not change.
*/
public const INPUT_NAME = '__csrf_magic';
/**
* csrf-magic's default hash. It never set csrf_conf('hash'), so every
* token Cacti ever issued used sha1.
*/
private const LEGACY_HASH = 'sha1';
private ?CsrfTokenManagerInterface $manager;
private bool $enabled;
private string $legacySecret;
private int $legacyExpiry;
private string $scriptUrl = '';
private string $nonce = '';
private bool $isHtml = false;
/**
* @param CsrfTokenManagerInterface|null $manager Null when Symfony is not
* available, which happens during a fresh install before composer has
* populated include/vendor.
* @param bool $enabled False on the CLI, where there is no session
* and no request to protect.
* @param string $legacySecret The csrf-magic secret, used only to accept
* tokens minted before the upgrade. Empty disables the fallback.
* @param int $legacyExpiry Seconds a legacy token stays acceptable,
* matching csrf-magic's configured expiry.
*/
public function __construct(?CsrfTokenManagerInterface $manager = null, bool $enabled = true, string $legacySecret = '', int $legacyExpiry = 7200) {
$this->manager = $manager;
$this->enabled = $enabled && $manager !== null;
$this->legacySecret = $legacySecret;
$this->legacyExpiry = $legacyExpiry;
}
/**
* @return bool True when tokens will actually be issued and checked.
*/
public function isEnabled() : bool {
return $this->enabled;
}
/**
* Issue a token for the current session.
*
* Symfony returns a freshly randomised encoding of one stored secret on
* every call, so successive calls return different strings that all
* validate. That is deliberate BREACH mitigation.
*
* @return string The token, or an empty string when the guard is disabled.
*/
public function token() : string {
if (!$this->enabled || $this->manager === null) {
return '';
}
return $this->manager->getToken(self::INTENTION_GLOBAL)->getValue();
}
/**
* @param string $value The submitted token.
*
* @return bool True only when the token is valid for this session.
*/
public function validate(string $value) : bool {
if (!$this->enabled || $this->manager === null || $value === '') {
return false;
}
if ($this->manager->isTokenValid(new CsrfToken(self::INTENTION_GLOBAL, $value))) {
return true;
}
return $this->validateLegacyHmac($value);
}
/**
* @param string $url URL of the XHR-patching script to inject.
*/
public function setScriptUrl(string $url) : void {
$this->scriptUrl = $url;
}
/**
* Supply a CSP nonce for the scripts this class injects.
*
* develop currently sends 'unsafe-inline', so this is unused today. 1.2.x
* has already moved to nonce-based CSP; when develop follows, the injected
* scripts would silently stop running and take every AJAX call with them.
*
* @param string $nonce The per-request nonce, or an empty string for none.
*/
public function setNonce(string $nonce) : void {
$this->nonce = $nonce;
}
/**
* Inject the CSRF token into POST forms and the XHR shim into the page.
*
* The form pattern is carried over from csrf-magic unchanged. It is
* deliberately not widened: every POST is validated, decorated or not, so
* a form this pattern misses already fails visibly rather than silently.
*
* The handler may be called repeatedly for a chunked response, so the
* "have we seen <html yet" decision is remembered on the instance.
*
* @param string $buffer One chunk of output.
*
* @return string The chunk, rewritten when it is HTML.
*/
public function rewriteBuffer(string $buffer) : string {
if (!$this->enabled) {
return $buffer;
}
if (!$this->isHtml) {
$this->isHtml = (stripos($buffer, '<html') !== false);
}
if (!$this->isHtml) {
return $buffer;
}
$rawToken = $this->token();
$token = htmlspecialchars($rawToken, ENT_QUOTES, 'UTF-8');
$name = self::INPUT_NAME;
$input = "<input type='hidden' name='$name' value=\"$token\" />";
$buffer = preg_replace('#(<form[^>]*method\s*=\s*["\']post["\'][^>]*>)#i', '$1' . $input, $buffer) ?? $buffer;
if ($this->scriptUrl === '') {
return $buffer;
}
$attr = ($this->nonce !== '' ? ' nonce="' . htmlspecialchars($this->nonce, ENT_QUOTES, 'UTF-8') . '"' : '');
$url = htmlspecialchars($this->scriptUrl, ENT_QUOTES, 'UTF-8');
/*
* The hidden field above is an HTML attribute, so htmlspecialchars() is
* correct there. These two values land inside a <script> string
* literal instead, a different context that HTML-entity escaping does
* not protect; json_encode() with the HEX_* flags is what makes the
* token correct by construction rather than safe only because
* Symfony's generator happens not to emit quotes today.
*/
$jsToken = json_encode($rawToken, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$jsName = json_encode($name, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$head = '<script type="text/javascript"' . $attr . '>' .
'var csrfMagicToken = ' . $jsToken . ';' .
'var csrfMagicName = ' . $jsName . ';</script>' .
'<script src="' . $url . '" type="text/javascript"' . $attr . '></script></head>';
$buffer = str_ireplace('</head>', $head, $buffer);
$end = '<script type="text/javascript"' . $attr . '>CsrfMagic.end();</script>';
$count = 0;
$buffer = str_ireplace('</body>', $end . '</body>', $buffer, $count);
if (!$count) {
$buffer .= $end;
}
return $buffer;
}
/**
* Accept a csrf-magic token minted before the upgrade.
*
* Only the 'sid:' form is honoured. Cacti configured csrf-magic in session
* mode, so its cookie, key, user and ip branches were unreachable and are
* not ported. The token carries its own issue time, so this path expires
* on its own arithmetic and needs no configuration flag.
*
* Remove this method, and the secret plumbing behind it, once the upgrade
* window has passed. See phase 2 of the design document.
*
* @param string $value The submitted token.
*
* @return bool True when the token is a live pre-upgrade token.
*/
private function validateLegacyHmac(string $value) : bool {
if ($this->legacySecret === '' || strpos($value, 'sid:') !== 0) {
return false;
}
$body = substr($value, 4);
if (strpos($body, ',') === false) {
return false;
}
[$hash, $time] = explode(',', $body, 2);
if (!ctype_digit($time)) {
return false;
}
if (time() >= (int) $time + $this->legacyExpiry) {
return false;
}
$inner = hash_hmac(self::LEGACY_HASH, $time . ':' . session_id(), $this->legacySecret);
$expected = hash_hmac(self::LEGACY_HASH, $inner, $this->legacySecret);
return hash_equals($expected, $hash);
}
}