mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-28 23:57:30 +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>
244 lines
6.4 KiB
PHP
244 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 |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
function issue7343ReservePort() : int {
|
|
$socket = stream_socket_server('tcp://127.0.0.1:0', $errorNumber, $errorMessage);
|
|
|
|
if ($socket === false) {
|
|
throw new RuntimeException("Unable to reserve an HTTP port: $errorMessage ($errorNumber)");
|
|
}
|
|
|
|
$address = stream_socket_get_name($socket, false);
|
|
fclose($socket);
|
|
|
|
if ($address === false || !preg_match('/:(\d+)$/', $address, $matches)) {
|
|
throw new RuntimeException('Unable to determine the reserved HTTP port');
|
|
}
|
|
|
|
return (int) $matches[1];
|
|
}
|
|
|
|
function issue7343StartServer(string $root, string $sessionPath, int $port) : array {
|
|
$command = [
|
|
PHP_BINARY,
|
|
'-d',
|
|
'session.save_path=' . $sessionPath,
|
|
'-d',
|
|
'display_errors=1',
|
|
'-S',
|
|
'127.0.0.1:' . $port,
|
|
$root . '/tests/fixtures/Issue7343InstallerCsrfEndpoint.php',
|
|
];
|
|
|
|
$descriptors = [
|
|
0 => ['pipe', 'r'],
|
|
1 => ['pipe', 'w'],
|
|
2 => ['pipe', 'w'],
|
|
];
|
|
|
|
$process = proc_open($command, $descriptors, $pipes, $root);
|
|
|
|
if (!is_resource($process)) {
|
|
throw new RuntimeException('Unable to start the PHP integration-test server');
|
|
}
|
|
|
|
fclose($pipes[0]);
|
|
stream_set_blocking($pipes[1], false);
|
|
stream_set_blocking($pipes[2], false);
|
|
|
|
$deadline = microtime(true) + 5;
|
|
|
|
do {
|
|
set_error_handler(static function () {
|
|
return true;
|
|
});
|
|
|
|
try {
|
|
$connection = stream_socket_client(
|
|
'tcp://127.0.0.1:' . $port,
|
|
$errorNumber,
|
|
$errorMessage,
|
|
0.1
|
|
);
|
|
} finally {
|
|
restore_error_handler();
|
|
}
|
|
|
|
if ($connection !== false) {
|
|
fclose($connection);
|
|
|
|
return [$process, $pipes];
|
|
}
|
|
|
|
$status = proc_get_status($process);
|
|
|
|
if (!$status['running']) {
|
|
break;
|
|
}
|
|
|
|
usleep(25000);
|
|
} while (microtime(true) < $deadline);
|
|
|
|
$error = stream_get_contents($pipes[2]);
|
|
issue7343StopServer($process, $pipes);
|
|
|
|
throw new RuntimeException('PHP integration-test server did not start: ' . trim($error));
|
|
}
|
|
|
|
function issue7343StopServer($process, array $pipes) : void {
|
|
$status = proc_get_status($process);
|
|
|
|
if ($status['running']) {
|
|
proc_terminate($process);
|
|
|
|
$deadline = microtime(true) + 2;
|
|
|
|
do {
|
|
usleep(25000);
|
|
$status = proc_get_status($process);
|
|
} while ($status['running'] && microtime(true) < $deadline);
|
|
|
|
if ($status['running']) {
|
|
proc_terminate($process, 9);
|
|
}
|
|
}
|
|
|
|
foreach ($pipes as $pipe) {
|
|
if (is_resource($pipe)) {
|
|
fclose($pipe);
|
|
}
|
|
}
|
|
|
|
proc_close($process);
|
|
}
|
|
|
|
function issue7343Post(string $url, array $data, string $cookie = '') : array {
|
|
$headers = [
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'X-Requested-With: XMLHttpRequest',
|
|
];
|
|
|
|
if ($cookie !== '') {
|
|
$headers[] = 'Cookie: ' . $cookie;
|
|
}
|
|
|
|
$context = stream_context_create(
|
|
[
|
|
'http' => [
|
|
'method' => 'POST',
|
|
'header' => implode("\r\n", $headers),
|
|
'content' => http_build_query($data),
|
|
'ignore_errors' => true,
|
|
'follow_location' => 0,
|
|
'timeout' => 5,
|
|
],
|
|
]
|
|
);
|
|
|
|
$body = file_get_contents($url, false, $context);
|
|
$responseHeaders = $http_response_header ?? [];
|
|
|
|
if ($body === false || !$responseHeaders) {
|
|
throw new RuntimeException('Installer CSRF integration request failed');
|
|
}
|
|
|
|
if (!preg_match('/\s(\d{3})(?:\s|$)/', $responseHeaders[0], $statusMatch)) {
|
|
throw new RuntimeException('Unable to parse HTTP response status');
|
|
}
|
|
|
|
$parsedHeaders = [];
|
|
|
|
foreach (array_slice($responseHeaders, 1) as $header) {
|
|
$separator = strpos($header, ':');
|
|
|
|
if ($separator === false) {
|
|
continue;
|
|
}
|
|
|
|
$name = strtolower(substr($header, 0, $separator));
|
|
$parsedHeaders[$name][] = trim(substr($header, $separator + 1));
|
|
}
|
|
|
|
return [
|
|
'status' => (int) $statusMatch[1],
|
|
'headers' => $parsedHeaders,
|
|
'body' => $body,
|
|
];
|
|
}
|
|
|
|
function issue7343SessionCookie(array $headers) : string {
|
|
foreach ($headers['set-cookie'] ?? [] as $header) {
|
|
$cookie = explode(';', $header, 2)[0];
|
|
|
|
if (str_starts_with($cookie, session_name() . '=')) {
|
|
return $cookie;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
test('installer csrf timeout returns a replacement token accepted by the next request', function () {
|
|
$root = dirname(__DIR__, 2);
|
|
$sessionPath = sys_get_temp_dir() . '/cacti-issue7343-' . bin2hex(random_bytes(8));
|
|
$port = issue7343ReservePort();
|
|
|
|
if (!mkdir($sessionPath, 0700) && !is_dir($sessionPath)) {
|
|
throw new RuntimeException('Unable to create the isolated PHP session directory');
|
|
}
|
|
|
|
$server = null;
|
|
$pipes = [];
|
|
|
|
try {
|
|
[$server, $pipes] = issue7343StartServer($root, $sessionPath, $port);
|
|
|
|
$url = 'http://127.0.0.1:' . $port . '/install/step_json.php';
|
|
$rejected = issue7343Post($url, ['__csrf_magic' => 'invalid']);
|
|
$payload = json_decode($rejected['body'], true);
|
|
$cookie = issue7343SessionCookie($rejected['headers']);
|
|
|
|
expect($rejected['status'])->toBe(403)
|
|
->and($rejected['headers']['content-type'][0] ?? '')->toStartWith('application/json')
|
|
->and($rejected['headers']['cache-control'] ?? [])->toContain('no-store')
|
|
->and($rejected['headers']['x-content-type-options'] ?? [])->toContain('nosniff')
|
|
->and($payload)->toBeArray()
|
|
->and($payload['error'] ?? null)->toBe('csrf_timeout')
|
|
->and($payload['csrfMagicToken'] ?? '')->not->toBe('')
|
|
->and($cookie)->not->toBe('');
|
|
|
|
$accepted = issue7343Post(
|
|
$url,
|
|
['__csrf_magic' => $payload['csrfMagicToken']],
|
|
$cookie
|
|
);
|
|
$acceptedPayload = json_decode($accepted['body'], true);
|
|
|
|
expect($accepted['status'])->toBe(200)
|
|
->and($acceptedPayload)->toBeArray()
|
|
->and($acceptedPayload['csrfValidated'] ?? false)->toBeTrue()
|
|
->and($acceptedPayload['csrfMagicToken'] ?? '')->not->toBe('');
|
|
} finally {
|
|
if (is_resource($server)) {
|
|
issue7343StopServer($server, $pipes);
|
|
}
|
|
|
|
foreach (glob($sessionPath . '/*') ?: [] as $sessionFile) {
|
|
unlink($sessionFile);
|
|
}
|
|
|
|
rmdir($sessionPath);
|
|
}
|
|
});
|