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>
This commit is contained in:
Thomas VincentandTheWitness authored and GitHub committed 2026-09-18 00:16:39 -04:00
1 parent cc7d14566c
commit cb49f31ff9
37 files changed
+2236 -1206

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
name: CSRF Coverage
on:
push:
branches: [develop]
paths:
- '.github/workflows/csrf-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'composer.json'
- 'composer.lock'
- 'include/csrf.php'
- 'lib/csrf.php'
- 'phpunit-csrf-coverage.xml'
- 'tests/Unit/Security/Csrf/**'
pull_request:
branches: [develop]
paths:
- '.github/workflows/csrf-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'composer.json'
- 'composer.lock'
- 'include/csrf.php'
- 'lib/csrf.php'
- 'phpunit-csrf-coverage.xml'
- 'tests/Unit/Security/Csrf/**'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: csrf-coverage-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
COMPOSER_ALLOW_SUPERUSER: 1
jobs:
coverage:
name: CSRF 100% coverage
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Setup locked PHP 8.3 dependencies
uses: ./.github/actions/setup-php-composer
with:
php-version: '8.3'
coverage: xdebug
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
dependency-mode: locked-csrf-coverage
install-args: --prefer-dist --no-progress --no-interaction
- name: Enforce line coverage
env:
XDEBUG_MODE: coverage
run: |
set -euo pipefail
include/vendor/bin/pest \
--configuration=phpunit-csrf-coverage.xml \
--coverage \
--coverage-text \
--min=100
+1
View File
@@ -185,6 +185,7 @@ Cacti CHANGELOG
-issue#7355: Add a JUNIPER-MIB jnxOperatingTable script data query for RE and FPC health
-issue#7355: Add an F5-BIGIP-SYSTEM-MIB script data query for per-CPU utilization
-issue#7904: Device changes are silently skipped when --bulk_walk is passed to change_device.php
-issue#7744: Replace the unmaintained csrf-magic library with symfony/security-csrf
-issue#7975: Reject malformed remember-me cookies without warnings or mutations
-issue#7978: Revoke remember-me tokens before clearing browser state
-issue#7328: Align graph, CSV export, percentile, and summation sample windows
+13 -36
View File
@@ -24,7 +24,6 @@
*/
require(__DIR__ . '/../include/cli_check.php');
require_once(CACTI_PATH_LIBRARY . '/CactiFilesystem.php');
require_once(CACTI_PATH_LIBRARY . '/poller.php');
require_once(CACTI_PATH_LIBRARY . '/utility.php');
@@ -64,39 +63,17 @@ if (cacti_sizeof($parms)) {
}
}
// issue warnings and start message if applicable
print 'NOTE: Updating csrf_secret file with new information' . PHP_EOL;
// CSRF tokens are session-bound random values now, not HMACs over this
// file, so rotating it no longer invalidates anything outstanding. Kept
// only so scripts and cron entries that still invoke it do not break; it
// will be removed in a future release once the pre-upgrade grace window
// (see CactiCsrfGuard::validateLegacyHmac()) is retired.
print 'NOTE: CSRF tokens are now session-bound and are not derived from a shared secret,' . PHP_EOL;
print ' so rotating the secret no longer invalidates anything.' . PHP_EOL;
print ' This file is retained only to validate tokens issued before the upgrade' . PHP_EOL;
print ' and will be removed in a future release.' . PHP_EOL;
if (!file_exists(CACTI_CSRF_SECRET)) {
print 'WARNING: csrf_secret.php file does not exist!' . PHP_EOL;
} elseif (!is_writable(CACTI_CSRF_SECRET)) {
print 'FATAL: Unable to replace csrf_secret.php!' . PHP_EOL;
exit(1);
}
$new_secret = csrf_generate_secret();
if (csrf_writable(CACTI_CSRF_SECRET)) {
umask(0027);
try {
$filesystem = new CactiFilesystem();
$filesystem->writeFile(CACTI_CSRF_SECRET, '<?php $secret = "' . $new_secret . '";' . PHP_EOL);
} catch (Symfony\Component\Filesystem\Exception\IOExceptionInterface) {
print 'FATAL: Unable to write new csrf_secret.php file.' . PHP_EOL;
exit(1);
}
print 'NOTE: New csrf_secret.php file written.' . PHP_EOL;
exit(0);
} else {
print 'FATAL: Unable to write new csrf_secret.php file.' . PHP_EOL;
exit(1);
}
exit(0);
/**
* display_version - displays version information
@@ -117,7 +94,7 @@ function display_help() : void {
display_version();
print PHP_EOL . 'usage: refresh_csrf.php' . PHP_EOL . PHP_EOL;
print 'A utility to update the csrf_secret() key on a the Cacti system. Updating' . PHP_EOL;
print 'this key should happen periodically during non-production hours as it can' . PHP_EOL;
print 'impact the user experience.' . PHP_EOL . PHP_EOL;
print 'Deprecated. CSRF tokens are session-bound and are not derived from a' . PHP_EOL;
print 'shared secret, so this utility no longer rotates anything. Retained' . PHP_EOL;
print 'only to avoid breaking scripts that still invoke it.' . PHP_EOL . PHP_EOL;
}
+8 -5
View File
@@ -60,11 +60,14 @@
"symfony/filesystem": "^7.4",
"symfony/finder": "^6.4 || ^7.4",
"symfony/http-foundation": "^7.4",
"symfony/lock": "^7.4",
"symfony/mailer": "^7.4",
"symfony/notifier": "^7.4",
"symfony/process": "^7.4",
"symfony/validator": "^7.4"
"symfony/lock": "^7.4",
"symfony/mailer": "^7.4",
"symfony/notifier": "^7.4",
"symfony/password-hasher": "^7.4",
"symfony/security-core": "^7.4",
"symfony/security-csrf": "^7.4",
"symfony/process": "^7.4",
"symfony/validator": "^7.4"
},
"require-dev": {
"composer/xdebug-handler": "^3.0",
Generated
+243 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "33ddeb7d701bf817126ad36feb9a92e0",
"content-hash": "d6fd3f72f711ebcdcffb507d3ef34915",
"packages": [
{
"name": "doctrine/lexer",
@@ -2829,6 +2829,82 @@
],
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/password-hasher",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/password-hasher.git",
"reference": "18a7d92126c95962f7efbcc9e421ba710a366847"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/password-hasher/zipball/18a7d92126c95962f7efbcc9e421ba710a366847",
"reference": "18a7d92126c95962f7efbcc9e421ba710a366847",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"conflict": {
"symfony/security-core": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/security-core": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\PasswordHasher\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Robin Chalas",
"email": "robin.chalas@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides password hashing utilities",
"homepage": "https://symfony.com",
"keywords": [
"hashing",
"password"
],
"support": {
"source": "https://github.com/symfony/password-hasher/tree/v7.4.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.37.0",
@@ -3560,6 +3636,172 @@
],
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/security-core",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-core.git",
"reference": "4fac226354298b8d66f680275b0daef1d3f580d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-core/zipball/4fac226354298b8d66f680275b0daef1d3f580d2",
"reference": "4fac226354298b8d66f680275b0daef1d3f580d2",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/event-dispatcher-contracts": "^2.5|^3",
"symfony/password-hasher": "^6.4|^7.0|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
"symfony/event-dispatcher": "<6.4",
"symfony/http-foundation": "<6.4",
"symfony/ldap": "<6.4",
"symfony/translation": "<6.4.3|>=7.0,<7.0.3",
"symfony/validator": "<6.4"
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0",
"psr/container": "^1.1|^2.0",
"psr/log": "^1|^2|^3",
"symfony/cache": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/ldap": "^6.4|^7.0|^8.0",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/string": "^6.4|^7.0|^8.0",
"symfony/translation": "^6.4.3|^7.0.3|^8.0",
"symfony/validator": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Security\\Core\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony Security Component - Core Library",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-core/tree/v7.4.18"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-08-30T00:47:26+00:00"
},
{
"name": "symfony/security-csrf",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-csrf.git",
"reference": "16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-csrf/zipball/16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d",
"reference": "16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/security-core": "^6.4|^7.0|^8.0"
},
"conflict": {
"symfony/http-foundation": "<6.4"
},
"require-dev": {
"psr/log": "^1|^2|^3",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Security\\Csrf\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony Security Component - CSRF Library",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-csrf/tree/v7.4.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/service-contracts",
"version": "v3.7.3",
+339 -17
View File
@@ -22,28 +22,352 @@
+-------------------------------------------------------------------------+
*/
require_once(CACTI_PATH_INCLUDE . '/vendor/csrf/csrf-conf.php');
require_once(CACTI_PATH_LIBRARY . '/csrf.php');
// cross site request forgery library
function csrf_startup() : void {
if (CACTI_WEB) {
// If you need to debug CSRF, uncomment the following line
// csrf_conf('log_file', dirname(read_config_option('path_cactilog')) . '/csrf.log');
if (CACTI_CSRF_SECRET != '') {
csrf_conf('path_secret', CACTI_CSRF_SECRET);
/**
* The process-wide CSRF guard.
*
* Built on first use so the CLI never touches Symfony. An explicit installer
* request may also run without it while Composer populates include/vendor;
* every other web request fails closed if the dependency cannot be loaded.
*
* @return CactiCsrfGuard The shared guard instance.
*/
function csrf_guard() : CactiCsrfGuard {
static $guard = null;
if ($guard !== null) {
return $guard;
}
if (!CACTI_WEB) {
$guard = new CactiCsrfGuard(null, false);
return $guard;
}
if (!class_exists('Symfony\Component\Security\Csrf\CsrfTokenManager')) {
/* The installer is the only web context allowed to run before Composer
has populated include/vendor. A normal request reaching this branch
has a missing, corrupt, or partially synchronized dependency tree and
must not continue without CSRF validation. */
if (!defined('IN_CACTI_INSTALL')) {
$message = 'FATAL: CSRF protection is unavailable because symfony/security-csrf could not be loaded. Restore Cacti Composer dependencies and reload this page.';
cacti_log($message, false, 'CSRF');
http_response_code(500);
print $message;
exit(1);
}
csrf_conf('rewrite-js', CACTI_PATH_URL . 'include/vendor/csrf/csrf-magic.js');
csrf_conf('callback', 'csrf_error_callback');
csrf_conf('expires', 7200);
} else {
csrf_conf('disable',true);
cacti_log('WARNING: symfony/security-csrf is not yet available during installation, so CSRF validation is disabled for this request', false, 'CSRF');
$guard = new CactiCsrfGuard(null, false);
return $guard;
}
$storage = new Symfony\Component\Security\Csrf\TokenStorage\NativeSessionTokenStorage();
$manager = new Symfony\Component\Security\Csrf\CsrfTokenManager(
new Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator(),
$storage
);
/* Read-only on purpose. csrf_get_secret() mints and persists a secret when
none exists, and the grace window must not create the very file phase 2
deletes. A missing secret simply disables the legacy fallback.
Do not trim. csrf-magic keyed its HMAC on the raw bytes of this file,
and cli/refresh_csrf.php writes PHP source with a trailing newline, so
stripping whitespace yields a different key and silently rejects every
pre-upgrade token on any site that ever rotated its secret. */
$secret_file = csrf_secret_file_path(defined('CACTI_CSRF_SECRET') ? CACTI_CSRF_SECRET : '');
$secret = ($secret_file != '' && is_readable($secret_file) ? (string) file_get_contents($secret_file) : '');
/* csrf-magic's own csrf_get_secret() tried $path_csrf_secret and then
fell back to its own directory regardless of that setting. A site
that pointed $path_csrf_secret elsewhere but never actually created
the file there still had a working secret at the old default, so
check it too before giving up on the grace window. */
if ($secret === '') {
$fallback = CACTI_PATH_INCLUDE . '/vendor/csrf/csrf-secret.php';
if (is_readable($fallback)) {
$secret = (string) file_get_contents($fallback);
}
}
$guard = new CactiCsrfGuard($manager, true, $secret, 7200);
$guard->setScriptUrl(CACTI_PATH_URL . 'include/js/csrf.js');
return $guard;
}
/**
* Register the output rewriter and validate the current request.
*
* Replaces csrf-magic's self-executing bootstrap, which ran from the bottom of
* the library file as a side effect of including it.
*/
function csrf_startup() : void {
$guard = csrf_guard();
if (!$guard->isEnabled()) {
return;
}
/* include/session.php registers session_write_close() as a shutdown
function, and shutdown functions run before output buffers flush. If
the token were minted lazily from inside the output buffer callback
below, the first mint on a POST that reaches csrf_check() via the
legacy fallback would write $_SESSION after the session already
closed, and the token rendered into the page would never validate.
Mint it now, while the session is still open. */
$guard->token();
ob_start(function ($buffer) use ($guard) {
return $guard->rewriteBuffer($buffer);
});
csrf_check();
}
/**
* @return string A token for the current session, or an empty string when the
* guard is disabled.
*/
function csrf_get_tokens() : string {
csrf_deprecated(__FUNCTION__);
return csrf_guard()->token();
}
/**
* Validate a POST request.
*
* csrf-magic returned a bool, and plugins such as the bundled audit plugin
* branch on it directly (`!csrf_check(false)`). A void return silently
* breaks that caller: `!null` is always true, so the check would appear to
* fail on every request. $fatal has the same meaning it always did: true
* routes a failed check to csrf_error_callback(), which exits; false hands
* the result back to the caller instead.
*
* @param bool $fatal Exit via csrf_error_callback() on failure when true;
* otherwise return false and let the caller decide.
*
* @return bool True for a non-POST request or a validated POST; false for a
* POST that failed validation and $fatal is false.
*/
function csrf_check(bool $fatal = true) : bool {
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
return true;
}
$submitted = filter_input(INPUT_POST, CactiCsrfGuard::INPUT_NAME, FILTER_UNSAFE_RAW);
$submitted = (is_string($submitted) ? $submitted : '');
if (csrf_guard()->validate($submitted)) {
return true;
}
if ($fatal) {
csrf_error_callback();
}
return false;
}
/**
* @param mixed $tokens One token, or a ';' separated list as csrf-magic sent.
*
* @return bool True when any supplied token validates.
*/
function csrf_check_tokens(mixed $tokens) : bool {
csrf_deprecated(__FUNCTION__);
if (is_string($tokens)) {
$tokens = explode(';', $tokens);
}
foreach ((array) $tokens as $token) {
if (csrf_guard()->validate((string) $token)) {
return true;
}
}
return false;
}
/**
* Accept and discard a csrf-magic configuration call.
*
* The guard is configured through its constructor. This exists so plugins
* that call csrf_conf() do not fatal.
*
* @param string $key The csrf-magic option name.
* @param mixed $value The value, ignored.
*/
function csrf_conf(string $key, mixed $value) : void {
csrf_deprecated(__FUNCTION__);
}
/**
* Log one deprecation notice per function per request.
*
* Silent at default verbosity. A plugin author opts in by raising the log
* level; logging unconditionally would flood cacti.log.
*
* @param string $function The deprecated function that was called.
*/
function csrf_deprecated(string $function) : void {
static $seen = [];
if (isset($seen[$function])) {
return;
}
$seen[$function] = true;
cacti_log($function . '() is deprecated and will be removed; use csrf_guard() instead', false, 'CSRF', POLLER_VERBOSITY_DEBUG);
}
/**
* Resolve the legacy secret configuration to a file.
*
* Older installer code accepted a directory and appended csrf-secret.php,
* even though config.php.dist documents a full file path. Preserve that
* behavior during the legacy-token grace window.
*
* @param string $path A configured file or directory path.
*
* @return string The resolved secret file path.
*/
function csrf_secret_file_path(string $path) : string {
if ($path !== '' && is_dir($path)) {
return rtrim($path, '/\\') . '/csrf-secret.php';
}
return $path;
}
/**
* Read the csrf-magic secret, or mint and persist one if none exists.
*
* Retained because install/functions.php writes this value during a fresh
* install and the legacy token fallback verifies against it. Removed in
* phase 2 along with the rest of the secret plumbing.
*
* @return string The secret, or an empty string when none can be established.
*/
function csrf_get_secret() : string {
static $secret = null;
if ($secret !== null) {
return $secret;
}
$file = csrf_secret_file_path(defined('CACTI_CSRF_SECRET') ? CACTI_CSRF_SECRET : '');
if ($file != '' && file_exists($file)) {
// the raw bytes are the key; see the note in csrf_guard()
$secret = (string) @file_get_contents($file);
if ($secret !== '') {
return $secret;
}
}
$secret = csrf_generate_secret();
if ($file != '' && csrf_writable($file)) {
$old_umask = umask(0027);
$fh = fopen($file, 'w');
if ($fh !== false) {
fwrite($fh, $secret);
fclose($fh);
}
umask($old_umask);
}
return $secret;
}
/**
* Generate a new secret.
*
* csrf-magic built this from mt_rand(), which is not a cryptographic source.
* This uses random_bytes().
*
* @return string A 64 character hex secret.
*/
function csrf_generate_secret() : string {
return hash('sha256', random_bytes(32));
}
/**
* @param string $path The path to test.
*
* @return bool True when $path can be written without leaving a file behind.
*/
function csrf_writable(string $path) : bool {
if ($path === '') {
return false;
}
if (substr($path, -1) === '/') {
return csrf_writable($path . uniqid((string) random_int(0, PHP_INT_MAX)) . '.tmp');
}
if (file_exists($path)) {
$fh = @fopen($path, 'a');
if ($fh === false) {
return false;
}
fclose($fh);
return true;
}
$fh = @fopen($path, 'w');
if ($fh === false) {
return false;
}
fclose($fh);
unlink($path);
return true;
}
/**
* Record a CSRF diagnostic.
*
* csrf-magic wrote these to its own log file. They go to cacti.log now, at a
* verbosity that is silent by default.
*
* @param string $function The reporting function.
* @param string $message What happened.
*/
function csrf_log(string $function, string $message) : void {
cacti_log($function . '(): ' . $message, false, 'CSRF', POLLER_VERBOSITY_DEBUG);
}
/**
* Handle a request that failed CSRF validation.
*
* The installer runs its steps over JSON, so a plain redirect leaves it stuck
* on a dead XHR. That branch answers with a scoped payload carrying a fresh
* token instead, which install.js retries against once. See issue #7343.
*/
function csrf_error_callback() : void {
// Resolve session fixation for PHP 5.4
session_regenerate_id();
// Invalidate the failed session id instead of leaving it usable until GC.
session_regenerate_id(true);
if (defined('IN_CACTI_INSTALL') &&
isset($GLOBALS['auth_json']) &&
@@ -82,5 +406,3 @@ function csrf_error_callback() : void {
exit;
}
include_once(CACTI_PATH_INCLUDE . '/vendor/csrf/csrf-magic.php');
+9 -1
View File
@@ -729,13 +729,21 @@ require_once(CACTI_PATH_LIBRARY . '/poller.php');
require_once(CACTI_PATH_LIBRARY . '/snmpagent.php');
require_once(CACTI_PATH_LIBRARY . '/aggregate.php');
require_once(CACTI_PATH_LIBRARY . '/api_automation.php');
require_once(CACTI_PATH_INCLUDE . '/csrf.php');
require_once(CACTI_PATH_INCLUDE . '/domain.php');
// The CSRF guard resolves Symfony classes as include/csrf.php is evaluated,
// so the autoloader has to be registered first. During a fresh install the
// vendor directory may not exist yet; CactiCsrfGuard degrades in that case.
if (is_file($vendor_autoload)) {
require_once($vendor_autoload);
}
require_once(CACTI_PATH_INCLUDE . '/csrf.php');
// csrf-magic ran this from the bottom of its own file, so including the
// library was what protected the request. The guard is invoked explicitly.
csrf_startup();
if ($config['is_web']) {
// raise a message and perform a page refresh if we've changed modes
if ($config['poller_id'] > 1) {
@@ -1,3 +1,25 @@
/*
+-------------------------------------------------------------------------+
| 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 |
+-------------------------------------------------------------------------+
| Derived from csrf-magic by Edward Z. Yang, BSD licensed. Maintained |
| by the Cacti Group since 2020; upstream has been inactive since 2016. |
| See include/js/csrf.js.LICENSE for the original BSD terms. |
+-------------------------------------------------------------------------+
*/
/**
* @file
*
File renamed without changes.
-126
View File
@@ -1,126 +0,0 @@
# News
## 1.1.0 released 2020-02-23
Bug Fixes
- In some server environments, being behind a load balancer and enabling IP
restrictions would be ineffective as other users would all appear to be from
the same remote address
Features
- Allow logging of the CSRF process which is useful for third party develoeprs
when they are trying to incorporate the library to see what steps are being
taken and from where.
Configuration var: `log_file`
- Allow logging to also be echoed to console
Configuration var: `log_echo`
- Allow specifying the location of the CSRF secret file as some package
maintainers may prefer to relocate the secret file to a hidden location that
is readable only when installing the package and not be the application.
Configuration var: `path_secret`
- Allow specifying the startup function as some callers may wish to keep in line
with their own code formats.
Configuration var `startup_func`
- Allow configuration of which hashing function to use. It is expected that the
user configuring this option will known what hash functions are availble or it
could cause runtime errors.
Configuration var `hash`
- Allow use of session_id() to be configured via configuration variable so that
other mechanisms can be utilsed instead.
Configuration var `session`
## 1.0.5 released 2014-07-24
Bug Fixes
- In some server environments, IP address was not being detected properly.
Thanks Bianka Martinovic for reporting.
Security Fixes
- Hashing now uses an HMAC to prevent length extension attacks.
Features
- New option 'disable' which allows you to conditionally disable the CSRF
protection. Requested by Justin Carlson.
## 1.0.4 released 2013-07-17
Security Fixes
- When secret key was not explicitly set, it was not being used by the
`csrf_hash()` function. Thanks sparticvs for reporting.
Features
- The default 'CSRF check failed' page now offers a handy 'Try again' button,
which resubmits the form.
Bug Fixes
- The fix for 1.0.3 inadvertantly turned off XMLHttpRequest
overloading for all browsers; it has now been fixed to only
apply to IE.
## 1.0.3 released 2012-01-31
Bug Fixes
- Internet Explorer 8 adds support for XMLHttpRequest.prototype,
but this support is broken for method overloading. We
explicitly disable JavaScript overloading for Internet Explorer.
Thanks Kelly Lu for reporting. <lubird@gmail.com>
- A global declaration was omitted, resulting in a variable
not being properly introduced in PHP 5.3. Thanks Whitney Beck for
reporting. <whitney.a.beck@gmail.com>
## 1.0.2 released 2009-03-08
Security Fixes
- Due to a typo, csrf-magic accidentally treated the secret key
as always present. This means that there was a possible CSRF
attack against users without any cookies. No attacks in the
wild were known at the time of this release. Thanks Jakub
Vrána for reporting.
## 1.0.1 released 2008-11-02
### New Features
- Support for composite tokens; this also fixes a bug with using
IP-based tokens for users with cookies disabled.
- Native support cookie tokens; use csrf_conf('cookie', $name) to
specify the name of a cookie that the CSRF token should be
placed in. This is useful if you have a Squid cache, and need
to configure it to ignore this token.
- Tips/tricks section in README.txt.
- There is now a two hour expiration time on all tokens. This
can be modified using csrf_conf('expires', $seconds).
- ClickJacking protection using an iframe breaker. Disable with
csrf_conf('frame-breaker', false).
Bug Fixes
- CsrfMagic.send() incorrectly submitted GET requests twice,
once without the magic token and once with the token. Reported
by Kelly Lu <lubird@gmail.com>.
-160
View File
@@ -1,160 +0,0 @@
# CSRF Magic
Add the following line to the top of all web-accessible PHP pages. If you have a
common file included by everything, put it there.
```php
include_once '/path/to/csrf-magic.php';
```
Do it, test it, then forget about it. csrf-magic is protecting you if nothing
bad happens. Read on if you run into problems.
## TIPS AND TRICKS
* If your JavaScript and AJAX is persistently getting errors, check the AJAX
section below on how to fix.
* The CSS overlay protection makes it impossible to display your website in
frame/iframe elements. You can disable it with csrf_conf('frame-breaker',
false) in your csrf_startup() function.
* csrf-magic will start a session. To disable, use csrf_conf('auto-session',
false) in your csrf_startup() function.
* The default error message is a little user unfriendly. Write your own
function which outputs an error message and set csrf_conf('callback',
'myCallbackFunction') in your csrf_startup() function.
* Make sure csrf_conf('secret', 'ABCDEFG') has something random in it. If the
directory csrf-magic.php is in is writable, csrf-magic will generate a secret
key for you in the csrf-secret.php file.
* Remember you can use auto_prepend to include csrf-magic.php on all your pages.
You may want to create a stub file which you can include that includes
csrf-magic.php as well as performs configuration.
* The default expiration time for tokens is two hours. If you expect your users
to need longer to fill out forms, be sure to enable double submission when the
token is invalid.
## AJAX
csrf-magic has the ability to dynamically rewrite AJAX requests which use
XMLHttpRequest. However, due to the invasiveness of this procedure, it is not
enabled by default. You can enable it by adding this code before you include
csrf-magic.php.
```php
function csrf_startup() {
csrf_conf('rewrite-js', '/web/path/to/csrf-magic.js');
}
// include_once '/path/to/csrf-magic.php';
```
**NOTE:** *Be sure to place csrf-magic.js somewhere web accessible.*
The default method CSRF Magic uses to rewrite AJAX requests will only work for
browsers with support for XmlHttpRequest.prototype (this excludes all versions
of Internet Explorer). See this page for more information:
[Prototypes and xmlhtprequest](http://stackoverflow.com/questions/664315/internet-explorer-8-prototypes-and-xmlhttprequest)
However, csrf-magic.js will automatically detect and play nice with the
following JavaScript frameworks:
* jQuery
* Prototype
* MooTools
* Ext
* Dojo
**Note:** As of 2013-07-16, it has been a long time since this manual support
has been updated, and some JavaScript libraries have placed their copies of XHR
in local variables in closures, which makes it difficult for us to monkey-patch
it in automatically.)
To rewrite your own JavaScript library to use csrf-magic.js, you should modify
your function that generates XMLHttpRequest to have this at the end:
```php
return new CsrfMagic(xhrObject);
```
With whatever xhrObject may be. If you have literal instances of XMLHttpRequest
in your code, find and replace `new XMLHttpRequest` with `new CsrfMagic`
(CsrfMagic will automatically instantiate an XMLHttpRequest object in a
cross-platform manner as necessary).
If you don't want csrf-magic monkeying around with your XMLHttpRequest object,
you can manually rewrite your AJAX code to include the variable. The important
information is stored in the global variables csrfMagicName and csrfMagicToken.
CsrfMagic.process may also be of interest, as it takes one parameter, a
querystring, and prepends the CSRF token to the value.
## BASIC CONFIGURATION
csrf-magic has some configuration options that you can set inside the
`csrf_startup()` function. They are described in csrf-magic.php, and you can set
them using the convenience `function csrf_conf($name, $value)`.
For example, this is a recommended configuration suggested by the upstream
providers. It should be noted that Cacti has a custom implementation of this
which is included with this repository.
```php
/**
* This is a function that gets called if a csrf check fails. csrf-magic will
* then exit afterwards.
*/
function my_csrf_callback() {
echo "You're doing bad things young man!";
}
function csrf_startup() {
// While csrf-magic has a handy little heuristic for determining whether
// or not the content in the buffer is HTML or not, you should really
// give it a nudge and turn rewriting *off* when the content is
// not HTML. Implementation details will vary.
if (isset($_POST['ajax'])) csrf_conf('rewrite', false);
// This is a secret value that must be set in order to enable username
// and IP based checks. Don't show this to anyone. A secret id will
// automatically be generated for you if the directory csrf-magic.php
// is placed in is writable.
csrf_conf('secret', 'ABCDEFG123456');
// This enables JavaScript rewriting and will ensure your AJAX calls
// don't stop working.
csrf_conf('rewrite-js', '/csrf-magic.js');
// This makes csrf-magic call my_csrf_callback() before exiting when
// there is a bad csrf token. This lets me customize the error page.
csrf_conf('callback', 'my_csrf_callback');
// While this is enabled by default to boost backwards compatibility,
// for security purposes it should ideally be off. Some users can be
// NATted or have dialup addresses which rotate frequently. Cookies
// are much more reliable.
csrf_conf('allow-ip', false);
}
// Finally, include the library
include_once '/path/to/csrf-magic.php';
```
The configuration for CSRF is stored in the PHP global array GLOBALS['csrf']
which is applied from csrf-conf.php in the same directory as the csrf-magic.php
and should be read to see what additional settings are available.
## THANKS
My thanks to Chris Shiflett, for unintentionally inspiring the idea, as well as
telling me the original variant of the Bob and Mallory story, and the Django
CSRF Middleware authors, who thought up of this before me. Gareth Heyes
suggested using the frame-breaker option to protect against CSS overlay attacks.
-158
View File
@@ -1,158 +0,0 @@
<?php
// CONFIGURATION:
/**
* Convenience parameter for disabling all of our functionality;
* equivalent to setting 'rewrite' to false and 'defer' to true.
*/
$GLOBALS['csrf']['disable'] = false;
/**
* By default, when you include this file csrf-magic will automatically check
* and exit if the CSRF token is invalid. This will defer executing
* csrf_check() until you're ready. You can also pass false as a parameter to
* that function, in which case the function will not exit but instead return
* a boolean false if the CSRF check failed. This allows for tighter integration
* with your system.
*/
$GLOBALS['csrf']['defer'] = false;
/**
* This is the amount of seconds you wish to allow before any token becomes
* invalid; the default is two hours, which should be more than enough for
* most websites.
*/
$GLOBALS['csrf']['expires'] = 7200;
/**
* Callback function to execute when there's the CSRF check fails and
* $fatal == true (see csrf_check). This will usually output an error message
* about the failure.
*/
$GLOBALS['csrf']['callback'] = 'csrf_callback';
/**
* Whether or not to include our JavaScript library which also rewrites
* AJAX requests on this domain. Set this to the web path. This setting only works
* with supported JavaScript libraries in Internet Explorer; see README.txt for
* a list of supported libraries.
*/
$GLOBALS['csrf']['rewrite-js'] = false;
/**
* A secret key used when hashing items. Please generate a random string and
* place it here. If you change this value, all previously generated tokens
* will become invalid.
*/
$GLOBALS['csrf']['secret'] = '';
// nota bene: library code should use csrf_get_secret() and not access
// this global directly
/**
* Set this to false to disable csrf-magic's output handler, and therefore,
* its rewriting capabilities. If you're serving non HTML content, you should
* definitely set this false.
*/
$GLOBALS['csrf']['rewrite'] = true;
/**
* Whether or not to use IP addresses when binding a user to a token. This is
* less reliable and less secure than sessions, but is useful when you need
* to give facilities to anonymous users and do not wish to maintain a database
* of valid keys.
*/
$GLOBALS['csrf']['allow-ip'] = true;
/**
* If this information is available, use the cookie by this name to determine
* whether or not to allow the request. This is a shortcut implementation
* very similar to 'key', but we randomly set the cookie ourselves.
*/
$GLOBALS['csrf']['cookie'] = '__csrf_cookie';
/**
* If this information is available, set this to a unique identifier (it
* can be an integer or a unique username) for the current "user" of this
* application. The token will then be globally valid for all of that user's
* operations, but no one else. This requires that 'secret' be set.
*/
$GLOBALS['csrf']['user'] = false;
/**
* This is an arbitrary secret value associated with the user's session. This
* will most probably be the contents of a cookie, as an attacker cannot easily
* determine this information. Warning: If the attacker knows this value, they
* can easily spoof a token. This is a generic implementation; sessions should
* work in most cases.
*
* Why would you want to use this? Lets suppose you have a squid cache for your
* website, and the presence of a session cookie bypasses it. Let's also say
* you allow anonymous users to interact with the website; submitting forms
* and AJAX. Previously, you didn't have any CSRF protection for anonymous users
* and so they never got sessions; you don't want to start using sessions either,
* otherwise you'll bypass the Squid cache. Setup a different cookie for CSRF
* tokens, and have Squid ignore that cookie for get requests, for anonymous
* users. (If you haven't guessed, this scheme was(?) used for MediaWiki).
*/
$GLOBALS['csrf']['key'] = false;
/**
* The name of the magic CSRF token that will be placed in all forms, i.e.
* the contents of <input type="hidden" name="$name" value="CSRF-TOKEN" />
*/
$GLOBALS['csrf']['input-name'] = '__csrf_magic';
/**
* Set this to false if your site must work inside of frame/iframe elements,
* but do so at your own risk: this configuration protects you against CSS
* overlay attacks that defeat tokens.
*/
$GLOBALS['csrf']['frame-breaker'] = true;
/**
* Whether or not CSRF Magic should prefer using the session id as the
* primary method of generating a secured token for validating the post
* data
*/
$GLOBALS['csrf']['session'] = true;
/**
* Whether or not CSRF Magic should be allowed to start a new session in order
* to determine the key.
*/
$GLOBALS['csrf']['auto-session'] = true;
/**
* Whether or not csrf-magic should produce XHTML style tags.
*/
$GLOBALS['csrf']['xhtml'] = true;
// FUNCTIONS:
// Don't edit this!
$GLOBALS['csrf']['version'] = '1.1.0';
/**
* Where to log output to if we need to
*/
$GLOBALS['csrf']['log_file'] = '';
/**
* Whether to echo logging to the secreen
*/
$GLOBALS['csrf']['log_echo'] = '';
/**
* Path to secret file
*/
$GLOBALS['csrf']['path_secret'] = '';
/**
* Startup function, normally called csrf_startup()
*/
$GLOBALS['csrf']['startup_func'] = '';
/**
* Hashing function to use, defaults to sha1
*/
$GLOBALS['csrf']['hash'] = '';
-609
View File
@@ -1,609 +0,0 @@
<?php
/**
* @file
*
* csrf-magic is a PHP library that makes adding CSRF-protection to your
* web applications a snap. No need to modify every form or create a database
* of valid nonces; just include this file at the top of every
* web-accessible page (or even better, your common include file included
* in every page), and forget about it! (There are, of course, configuration
* options for advanced users).
*
* This library is PHP4 and PHP5 compatible and is maintained from
* https://github.com/ezyang/csrf-magic/
*/
/**
* Rewrites <form> on the fly to add CSRF tokens to them. This can also
* inject our JavaScript library.
*/
function csrf_ob_handler($buffer, $flags) {
// Even though the user told us to rewrite, we should do a quick heuristic
// to check if the page is *actually* HTML. We don't begin rewriting until
// we hit the first <html tag.
static $is_html = false;
if (!$is_html) {
// not HTML until proven otherwise
$is_html = (stripos($buffer, '<html') !== false);
}
if ($is_html) {
$tokens = csrf_get_tokens();
$name = $GLOBALS['csrf']['input-name'];
$endslash = $GLOBALS['csrf']['xhtml'] ? ' /' : '';
$input = "<input type='hidden' name='$name' value=\"$tokens\"$endslash>";
$buffer = preg_replace('#(<form[^>]*method\s*=\s*["\']post["\'][^>]*>)#i', '$1' . $input, $buffer);
if ($GLOBALS['csrf']['frame-breaker']) {
$buffer = str_ireplace('</head>', '<script type="text/javascript">if (top != self) {top.location.href = self.location.href;}</script></head>', $buffer);
}
$js = $GLOBALS['csrf']['rewrite-js'];
if (!empty($js)) {
$buffer = str_ireplace(
'</head>',
'<script type="text/javascript">'.
'var csrfMagicToken = "'.$tokens.'";'.
'var csrfMagicName = "'.$name.'";</script>'.
'<script src="'.$js.'" type="text/javascript"></script></head>',
$buffer
);
$script = '<script type="text/javascript">CsrfMagic.end();</script>';
$buffer = str_ireplace('</body>', $script . '</body>', $buffer, $count);
if (!$count) {
$buffer .= $script;
}
}
}
csrf_log(__FUNCTION__, 'returns: ' . var_export($buffer, true));
return $buffer;
}
/**
* Checks if this is a post request, and if it is, checks if the nonce is valid.
* @param bool $fatal Whether or not to fatally error out if there is a problem.
* @return True if check passes or is not necessary, false if failure.
*/
function csrf_check($fatal = true) {
$result = true;
if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST') {
$result = false;
csrf_start();
$name = $GLOBALS['csrf']['input-name'];
$result = isset($_POST[$name]);
$tokens = '';
csrf_log(__FUNCTION__, "csrf magic $name was $result");
if ($result) {
// we don't regenerate a token and check it because some token creation
// schemes are volatile.
$tokens = $_POST[$name];
$result = csrf_check_tokens($tokens);
if (is_array($tokens)) {
$tokens = implode(';', $tokens);
}
csrf_log(__FUNCTION__, "check_tokens($name, $tokens) returned $result");
}
if ($fatal && !$result) {
$callback = $GLOBALS['csrf']['callback'];
// filter tokens to ensure only valid tokens passed
if (trim($tokens, 'A..Za..z0..9:;,') !== '') {
$tokens = 'hidden';
}
$callback($tokens);
exit;
}
}
csrf_log(__FUNCTION__, 'returns: ' . var_export($result, true));
return $result;
}
/**
* Retrieves a valid token(s) for a particular context. Tokens are separated
* by semicolons.
*/
function csrf_get_tokens() {
$has_cookies = !empty($_COOKIE);
// $ip implements a composite key, which is sent if the user hasn't sent
// any cookies. It may or may not be used, depending on whether or not
// the cookies "stick"
$secret = csrf_get_secret();
$token = '';
$ip = '';
if (!$has_cookies && $secret) {
$ip = csrf_get_client_addr();
if (!empty($ip)) {
$ip = ';ip:' . csrf_hash($_SERVER['REMOTE_ADDR']);
}
}
csrf_start();
// These are "strong" algorithms that don't require per se a secret
if ($GLOBALS['csrf']['session'] && session_id()) {
$token = 'sid:' . csrf_hash(session_id()) . $ip;
} elseif ($GLOBALS['csrf']['cookie']) {
$val = csrf_generate_secret();
setcookie($GLOBALS['csrf']['cookie'], $val, time() + 3600, $GLOBALS['csrf']['url_path']);
$token = 'cookie:' . csrf_hash($val) . $ip;
} elseif ($GLOBALS['csrf']['key']) {
$token = 'key:' . csrf_hash($GLOBALS['csrf']['key']) . $ip;
} elseif (!$secret) {
$token = 'invalid';
} elseif ($GLOBALS['csrf']['user'] !== false) {
$token = 'user:' . csrf_hash($GLOBALS['csrf']['user']);
} elseif ($GLOBALS['csrf']['allow-ip']) {
$token = ltrim($ip, ';');
} else {
$token = 'invalid';
}
csrf_log(__FUNCTION__, 'returns: ' . var_export($token, true));
return $token;
}
function csrf_flattenpost($data) {
$ret = array();
foreach($data as $n => $v) {
$ret = array_merge($ret, csrf_flattenpost2(1, $n, $v));
}
csrf_log(__FUNCTION__, 'returns: ' . var_export($ret, true));
return $ret;
}
function csrf_flattenpost2($level, $key, $data) {
if(!is_array($data)) {
$ret = array($key => $data);
} else {
$ret = array();
foreach($data as $n => $v) {
$nk = $level >= 1 ? $key."[$n]" : "[$n]";
$ret = array_merge($ret, csrf_flattenpost2($level+1, $nk, $v));
}
}
csrf_log(__FUNCTION__, 'returns: ' . var_export($ret, true));
return $ret;
}
/**
* @param $tokens is safe for HTML consumption
*/
function csrf_callback($tokens) {
// (yes, $tokens is safe to echo without escaping)
$data = '';
foreach (csrf_flattenpost($_POST) as $key => $value) {
if ($key != $GLOBALS['csrf']['input-name']) {
$data .= '<input type="hidden" name="'.htmlspecialchars($key).'" value="'.htmlspecialchars($value).'" />';
}
}
echo "<html>
<head>
<title>CSRF check failed</title>
</head>
<body>
<p>
CSRF check failed. Your form session may have expired, or you may not have cookies
enabled.
</p>
<form method='post' action=''>$data<button type='submit' class='ui-button ui-corner-all ui-widget ui-state-active'>" . __esc('Try Again'). "</button></form>
<p>Debug: $tokens</p></body></html>
</body>
</html>";
}
/**
* Checks if a composite token is valid. Outward facing code should use this
* instead of csrf_check_token()
*/
function csrf_check_tokens($tokens) {
if (is_string($tokens)) {
$tokens = explode(';', $tokens);
}
$valid_token = false;
foreach ($tokens as $token) {
if (csrf_check_token($token)) {
$valid_token = true;
break;
}
}
csrf_log(__FUNCTION__, 'returns: ' . var_export($valid_token, true));
return $valid_token;
}
/**
* Checks if a token is valid.
*/
function csrf_check_token($token) {
$valid_token = false;
if (strpos($token, ':') !== false) {
list($type, $value) = explode(':', $token, 2);
if (strpos($value, ',') !== false) {
list($x, $time) = explode(',', $token, 2);
$check_token = true;
if ($GLOBALS['csrf']['expires']) {
$expiry_time = time();
$expiry_csrf = $time + $GLOBALS['csrf']['expires'];
$check_token = ($expiry_time < $expiry_csrf);
csrf_log(__FUNCTION__, "expiry $check_token = $expiry_time < $expiry_csrf");
}
if ($check_token) {
switch ($type) {
case 'sid':
$valid_token = ($value === csrf_hash(session_id(), $time));
break;
case 'cookie':
$n = $GLOBALS['csrf']['cookie'];
if ($n && isset($_COOKIE[$n])) {
$valid_token = ($value === csrf_hash($_COOKIE[$n], $time));
}
break;
case 'key':
if ($GLOBALS['csrf']['key']) {
$valid_token = ($value === csrf_hash($GLOBALS['csrf']['key'], $time));
}
break;
// We could disable these 'weaker' checks if 'key' was set, but
// that doesn't make me feel good then about the cookie-based
// implementation.
case 'user':
if (csrf_get_secret() && $GLOBALS['csrf']['user'] !== false) {
$valid_token = ($value === csrf_hash($GLOBALS['csrf']['user'], $time));
}
break;
case 'ip':
// do not allow IP-based checks if the username is set, or if
// the browser sent cookies
if (csrf_get_secret() &&
$GLOBALS['csrf']['user'] === false &&
empty($_COOKIE) &&
$GLOBALS['csrf']['allow-ip']) {
$client_ip = csrf_get_client_addr();
if (!empty($client_ip)) {
$valid_token = ($value === csrf_hash($client_ip, $time));
}
}
break;
}
csrf_log(__FUNCTION__, 'Checking ' . $type . ' resulted ' . $valid_token);
}
}
}
csrf_log(__FUNCTION__, 'returns: ' . var_export($valid_token, true));
return $valid_token;
}
/**
* Sets a configuration value.
*/
function csrf_conf($key, $val) {
if (!isset($GLOBALS['csrf'][$key])) {
trigger_error('No such configuration ' . $key, E_USER_WARNING);
} else {
$old_val = $GLOBALS['csrf'][$key];
$GLOBALS['csrf'][$key] = $val;
//csrf_log(__FUNCTION__,'Configuration option [' . $key . '] set to [' . $val . '] (was [' . $old_val . '])');
}
}
/**
* Starts a session if we're allowed to.
*/
function csrf_start() {
global $config;
if ($GLOBALS['csrf']['auto-session'] && !session_id()) {
session_start();
}
}
/**
* Retrieves the secret, and generates one if necessary.
*/
function csrf_get_secret() {
$secret = '';
$files = array();
if ($GLOBALS['csrf']['secret']) {
$secret = $GLOBALS['csrf']['secret'];
}
if (empty($secret)) {
if (isset($GLOBALS['csrf']['path_secret'])) {
$files[] = $GLOBALS['csrf']['path_secret'];
}
$files[] = __DIR__ . '/csrf-secret.php';
}
foreach ($files as $file) {
$dir = dirname($file);
if (file_exists($file)) {
$secret = @file_get_contents($file);
if (!empty($secret)) {
break;
}
}
}
if (empty($secret)) {
$new_secret = csrf_generate_secret();
foreach ($files as $file) {
if (csrf_writable($file)) {
$old_umask = umask(0027);
$fh = fopen($file, 'w');
fwrite($fh, $new_secret);
fclose($fh);
umask($old_umask);
$secret = $new_secret;
break;
}
}
}
$GLOBALS['csrf']['secret'] = $secret;
csrf_log(__FUNCTION__, 'returns: ' . var_export($secret, true));
return $secret;
}
/**
* Generates a random string as the hash of time, microtime, and mt_rand.
*/
function csrf_generate_secret($len = 32) {
$r = '';
for ($i = 0; $i < $len; $i++) {
$r .= chr(mt_rand(0, 255));
}
$r .= time() . microtime();
$secret = csrf_internal_hash('',$r);
csrf_log(__FUNCTION__, 'returns: ' . var_export($secret, true));
return $secret;
}
function csrf_internal_hash($secret, $value) {
$hash_func = 'sha1'; // fall back hash func
if (!empty($GLOBALS['csrf']['hash'])) {
$hash_func = $GLOBALS['csrf']['hash'];
}
if (function_exists("hash_hmac")) {
$result = hash_hmac($hash_func, $value, $secret);
} else {
$result = $hash_func($secret . $value);
}
return $result;
}
/**
* Generates a hash/expiry double. If time isn't set it will be calculated
* from the current time.
*/
function csrf_hash($value, $time = null) {
if (!$time) {
$time = time();
}
$secret = csrf_get_secret();
$result = csrf_internal_hash($secret, csrf_internal_hash($secret, $time . ':' . $value)) . ',' . $time;
csrf_log(__FUNCTION__, 'returns: ' . var_export($result, true));
return $result;
}
function csrf_get_client_addr() {
$http_addr_headers = array(
'X-Forwarded-For',
'X-Client-IP',
'X-Real-IP',
'X-ProxyUser-Ip',
'CF-Connecting-IP',
'True-Client-IP',
'HTTP_X_FORWARDED',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'HTTP_CLIENT_IP',
'REMOTE_ADDR',
);
$client_addr = false;
foreach ($http_addr_headers as $header) {
if (!empty($_SERVER[$header])) {
$header_ips = explode(',', $_SERVER[$header]);
foreach ($header_ips as $header_ip) {
if (!empty($header_ip)) {
if (!filter_var($header_ip, FILTER_VALIDATE_IP)) {
csrf_log(__FUNCTION__, 'ERROR: Invalid remote client IP Address found in header (' . $header . ').');
} else {
$client_addr = $header_ip;
csrf_log(__FUNCTION__, 'DEBUG: Using remote client IP Address found in header (' . $header . '): ' . $client_addr . ' (' . $_SERVER[$header] . ')');
break;
}
}
}
}
}
csrf_log(__FUNCTION__, 'returns: ' . var_export($client_addr, true));
return $client_addr;
}
function csrf_writable($path) {
if (empty($path)) {
return false;
}
if ($path[strlen($path)-1] == '/') {
return csrf_writable($path . uniqid(mt_rand()) . '.tmp');
}
if (file_exists($path)) {
if (($f = @fopen($path, 'a'))) {
fclose($f);
return true;
}
return false;
}
if (($f = @fopen($path, 'w'))) {
fclose($f);
unlink($path);
return true;
}
return false;
}
function csrf_log($name, $text) {
$log_file = '';
if (!empty($GLOBALS['csrf']['log_file'])) {
$log_file = $GLOBALS['csrf']['log_file'];
if (!csrf_writable($log_file)) {
die('ERROR: CSRF Log file unavailable: ' . $log_file . PHP_EOL);
}
$log_file = rtrim($log_file, '.log').'_'.sha1(session_id()).'.log';
$d = new DateTime();
$l = sprintf('[%20s] [%20s] [%s] %s%s%s%s%s%s%s', $d->Format('Y-m-d H:i:s.u'), $name, csrf_caller(), PHP_EOL, $text, PHP_EOL, PHP_EOL, csrf_backtrace('',0,2), PHP_EOL, PHP_EOL);
file_put_contents($log_file, $l, FILE_APPEND);
if (!empty($GLOBALS['csrf']['log_echo'])) {
print $l;
}
}
}
function csrf_caller() {
static $caller = '';
if (empty($caller)) {
if (!empty($_SERVER['REQUEST_URI'])) {
$caller = $_SERVER['REQUEST_URI'];
} else {
$caller = $_SERVER['SCRIPT_NAME'];
}
}
return $caller;
}
function csrf_backtrace($entry = '', $limit = 0, $skip = 0) {
global $config;
$skip = $skip >= 0 ? $skip : 1;
$limit = $limit > 0 ? ($limit + $skip) : 0;
$callers = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $limit);
while ($skip > 0) {
array_shift($callers);
$skip--;
}
$s='';
foreach ($callers as $c) {
if (isset($c['line'])) {
$line = '[' . $c['line'] . ']';
} else {
$line = '';
}
if (isset($c['file'])) {
if (isset($config['base_path'])) {
$file = str_replace($config['base_path'], '', $c['file']) . $line;
} else {
$file = $c['file'] . $line;
}
} else {
$file = $line;
}
$func = $c['function'].'()';
if (isset($c['class'])) {
$func = $c['class'] . $c['type'] . $func;
}
$s = sprintf('%30s : %s' . PHP_EOL, $func, $file) . $s;
}
return $s;
}
/****** MAIN CODE ******/
require_once(__DIR__ . '/csrf-conf.php');
if (!empty($GLOBALS['csrf']['startup'])) {
$csrf_startup_func = $GLOBALS['csrf']['startup'];
} elseif (function_exists('csrf_startup')) {
$csrf_startup_func = 'csrf_startup';
}
if (function_exists($csrf_startup_func)) {
call_user_func($csrf_startup_func);
}
if (!empty($_POST)) {
csrf_log('<post>', var_export($_POST, true));
}
if (!empty($_GET)) {
csrf_log('<get>', var_export($_GET, true));
}
if (!$GLOBALS['csrf']['disable']) {
// Initialize our handler
if ($GLOBALS['csrf']['rewrite']) {
ob_start('csrf_ob_handler');
}
// Perform check
if (!$GLOBALS['csrf']['defer']) {
csrf_check();
}
}
-25
View File
@@ -1,25 +0,0 @@
<?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/ |
+-------------------------------------------------------------------------+
*/
header("Location:../index.php");
+16 -7
View File
@@ -267,16 +267,25 @@ function prime_default_settings() : void {
}
function install_create_csrf_secret(string $file) : bool {
$file = csrf_secret_file_path($file);
if (!file_exists($file)) {
if (is_resource_writable($file)) {
// Write the file
$fh = fopen($file, 'w');
/* Match csrf_get_secret(): the legacy HMAC secret must not be
created world-readable when the web server uses umask 0022. */
$old_umask = umask(0027);
if ($fh !== false) {
fwrite($fh, csrf_get_secret());
fclose($fh);
} else {
return false;
try {
$fh = fopen($file, 'w');
if ($fh !== false) {
fwrite($fh, csrf_get_secret());
fclose($fh);
} else {
return false;
}
} finally {
umask($old_umask);
}
return true;
+261
View File
@@ -0,0 +1,261 @@
<?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);
}
}
+22 -32
View File
@@ -599,13 +599,12 @@ class Installer implements JsonSerializable {
'spikekill' => CACTI_PATH_CACHE . '/spikekill'
];
$csrf_path = CACTI_CSRF_SECRET;
if (is_dir($csrf_path)) {
$csrf_path = rtrim($csrf_path ?? '', '/') . '/csrf-secret.php';
}
$install_paths['csrf'] = $csrf_path;
/* The csrf secret is deliberately absent from $install_paths. It only
feeds the legacy token fallback for sites upgrading from csrf-magic,
which a new install has no use for, and its historical default lives
under a directory that no longer ships. Demanding it here failed the
permission step, and setStep() clamps to the first errored step, so a
fresh install could never advance past a path the user cannot create. */
$install_key = 'always';
@@ -619,24 +618,7 @@ class Installer implements JsonSerializable {
$permissions[$install_key][$path . '/'] = $valid;
} else {
$valid = false;
// Lets see if this is the CSRF secret
if ($name == 'csrf') {
// Does it exist?
if (file_exists($path)) {
// Lets get the contents and make sure we have something at least
$csrf_secret = file_get_contents($path);
$valid = !empty($csrf_secret);
}
}
// If we aren't valid at this point, we aren't CSRF
// or the CSRF was empty
if (!$valid) {
// Lets me sure this path is writable
$valid = (is_resource_writable($path));
}
$valid = (is_resource_writable($path));
$permissions[$install_key][$path] = $valid;
}
@@ -793,20 +775,28 @@ class Installer implements JsonSerializable {
$this->setProgress(Installer::PROGRESS_CSRF_BEGIN);
if (CACTI_CSRF_SECRET != '') {
log_install_debug('csrf', 'setCSRFSecret(): secret ' . CACTI_CSRF_SECRET);
$file = csrf_secret_file_path(CACTI_CSRF_SECRET);
$secret = @file_exists(CACTI_CSRF_SECRET) ? file_get_contents(CACTI_CSRF_SECRET) : '';
log_install_debug('csrf', 'setCSRFSecret(): secret ' . $file);
$secret = @file_exists($file) ? file_get_contents($file) : '';
log_install_debug('csrf', 'setCSRFSecret(): secret ' . (empty($secret) ? 'not ' : '') . 'empty');
if (empty($secret)) {
if (is_resource_writable(CACTI_CSRF_SECRET)) {
log_install_medium('csrf', 'setCSRFSecret(): Updated CSRF secret - "' . CACTI_CSRF_SECRET . '"');
install_create_csrf_secret(CACTI_CSRF_SECRET);
if (!is_dir(dirname($file))) {
/* The historical default sits under the retired csrf-magic
directory, so on a new install there is nowhere to write.
Nothing needs one either: the secret exists only to verify
tokens minted before the upgrade. */
log_install_debug('csrf', 'setCSRFSecret(): No directory for "' . $file . '", skipping');
} elseif (is_resource_writable($file)) {
log_install_medium('csrf', 'setCSRFSecret(): Updated CSRF secret - "' . $file . '"');
install_create_csrf_secret($file);
} else {
log_install_high('csrf', 'setCSRFSecret(): Unable to create file - "' . CACTI_CSRF_SECRET . '"');
log_install_high('csrf', 'setCSRFSecret(): Unable to create file - "' . $file . '"');
}
} else {
log_install_debug('csrf', 'setCSRFSecret(): Secret already exists - "' . CACTI_CSRF_SECRET . '"');
log_install_debug('csrf', 'setCSRFSecret(): Secret already exists - "' . $file . '"');
}
}
+23
View File
@@ -0,0 +1,23 @@
<phpunit bootstrap="tests/bootstrap-unit.php" beStrictAboutOutputDuringTests="false">
<php>
<ini name="error_reporting" value="-1"/>
<env name="CACTI_TEST_BOOTSTRAP" value="1"/>
</php>
<source>
<include>
<file>lib/csrf.php</file>
</include>
</source>
<testsuites>
<testsuite name="CSRF coverage">
<file>tests/Unit/Security/Csrf/CactiCsrfGuardLegacyTest.php</file>
<file>tests/Unit/Security/Csrf/CactiCsrfGuardTest.php</file>
<file>tests/Unit/Security/Csrf/CactiCsrfRewriteTest.php</file>
<file>tests/Unit/Security/Csrf/CsrfBootstrapOrderTest.php</file>
<file>tests/Unit/Security/Csrf/CsrfFacadeTest.php</file>
<file>tests/Unit/Security/Csrf/CsrfScriptAssetTest.php</file>
<file>tests/Unit/Security/Csrf/CsrfVendorTest.php</file>
<file>tests/Unit/Security/Csrf/CsrfSecretNotTrackedTest.php</file>
</testsuite>
</testsuites>
</phpunit>
@@ -20,7 +20,7 @@ test('Composer output is rejected while legacy paths remain allowed', function (
$tracked = [
'include/vendor/composer/index.php',
'include/vendor/composer/installed.json',
'include/vendor/csrf/README.md',
'include/vendor/parsedown/Parsedown.php',
];
expect(cacti_vendor_policy_unexpected_files($tracked))
@@ -0,0 +1,182 @@
<?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))');
});
@@ -0,0 +1,80 @@
<?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 |
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Security\Csrf\CsrfTokenManager;
use Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator;
use Symfony\Component\Security\Csrf\TokenStorage\NativeSessionTokenStorage;
require_once CACTI_PATH_LIBRARY . '/csrf.php';
/*
* Build a guard over a real Symfony manager backed by the process $_SESSION.
* NativeSessionTokenStorage reads and writes $_SESSION directly, so no HTTP
* foundation or session handler is needed; seeding the superglobal is enough.
*/
function csrf_test_guard() : CactiCsrfGuard {
$_SESSION = [];
$storage = new NativeSessionTokenStorage();
$manager = new CsrfTokenManager(new UriSafeTokenGenerator(), $storage);
return new CactiCsrfGuard($manager, true);
}
test('a freshly issued token validates', function () {
$guard = csrf_test_guard();
expect($guard->validate($guard->token()))->toBeTrue();
});
test('a mutated token does not validate', function () {
$guard = csrf_test_guard();
$token = $guard->token();
expect($guard->validate($token . 'x'))->toBeFalse();
expect($guard->validate(strrev($token)))->toBeFalse();
});
/*
* Symfony randomises the encoding on every getToken() call to mitigate BREACH.
* The hidden form field and the JS global therefore hold different strings on
* the same page render, and both must validate. This looks like a bug and is
* not one.
*/
test('two issued tokens differ and both validate', function () {
$guard = csrf_test_guard();
$first = $guard->token();
$second = $guard->token();
expect($first)->not->toBe($second);
expect($guard->validate($first))->toBeTrue();
expect($guard->validate($second))->toBeTrue();
});
test('empty and malformed values are rejected', function () {
$guard = csrf_test_guard();
$guard->token();
expect($guard->validate(''))->toBeFalse();
expect($guard->validate('not-a-token'))->toBeFalse();
});
test('a disabled guard issues nothing and validates nothing', function () {
$guard = new CactiCsrfGuard(null, false);
expect($guard->isEnabled())->toBeFalse();
expect($guard->token())->toBe('');
expect($guard->validate('anything'))->toBeFalse();
});
@@ -0,0 +1,142 @@
<?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);
});
@@ -0,0 +1,77 @@
<?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 |
+-------------------------------------------------------------------------+
*/
/*
* CactiCsrfGuard resolves Symfony classes while include/csrf.php is being
* evaluated, so the Composer autoloader has to be registered first. This is an
* include-ordering property of a single file; there is no runtime seam to
* assert against, so the source order is the assertion.
*/
$root = dirname(__DIR__, 4);
test('the composer autoloader is required before include/csrf.php', function () use ($root) {
$src = file_get_contents($root . '/include/global.php');
$autoload = strpos($src, 'require_once($vendor_autoload);');
$csrf = strpos($src, "require_once(CACTI_PATH_INCLUDE . '/csrf.php');");
expect($autoload)->not->toBeFalse();
expect($csrf)->not->toBeFalse();
expect($autoload)->toBeLessThan($csrf);
});
/*
* csrf-magic validated the request from the bottom of its own file, so merely
* including it protected every POST. Nothing does that implicitly now: if this
* call goes missing, every page still renders and every form still carries a
* token, but no submission is ever checked.
*/
test('include/global.php invokes the guard after loading it', function () use ($root) {
$src = file_get_contents($root . '/include/global.php');
$include = strpos($src, "require_once(CACTI_PATH_INCLUDE . '/csrf.php');");
$startup = strpos($src, 'csrf_startup();');
expect($startup)->not->toBeFalse()
->and($include)->not->toBeFalse()
->and($include)->toBeLessThan($startup);
});
/*
* include/session.php registers session_write_close() as a shutdown
* function, and shutdown functions run before output buffers flush. A token
* minted lazily from inside the ob_start() callback would therefore write
* $_SESSION after the session already closed on any request where that
* callback runs the first mint of the request. Exercising this end to end
* would require a real shutdown-function pass, which Pest cannot simulate
* mid-test, so this asserts the fix at the source level: csrf_startup()
* must call token() before it registers the output handler.
*/
test('csrf_startup mints a token before registering the output handler', function () use ($root) {
$src = file_get_contents($root . '/include/csrf.php');
$start = strpos($src, 'function csrf_startup(');
expect($start)->not->toBeFalse();
$end = strpos($src, "\nfunction ", $start + 1);
$body = substr($src, $start, ($end === false ? strlen($src) : $end) - $start);
$token = strpos($body, '$guard->token();');
$obStart = strpos($body, 'ob_start(');
expect($token)->not->toBeFalse()
->and($obStart)->not->toBeFalse()
->and($token)->toBeLessThan($obStart);
});
+323
View File
@@ -0,0 +1,323 @@
<?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');
});
@@ -0,0 +1,38 @@
<?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 XHR shim is Cacti's own code and has been since 2020. It lives under
* include/js with the rest of Cacti's JavaScript, not under include/vendor
* where composer manages the contents.
*/
$root = dirname(__DIR__, 4);
test('the CSRF script ships from include/js', function () use ($root) {
expect(is_file($root . '/include/js/csrf.js'))->toBeTrue();
});
test('the shim still defines the CsrfMagic object plugins call', function () use ($root) {
$src = file_get_contents($root . '/include/js/csrf.js');
expect($src)->toContain('CsrfMagic');
expect($src)->toContain('XMLHttpRequest');
});
test('the script carries a Cacti copyright header', function () use ($root) {
$src = file_get_contents($root . '/include/js/csrf.js');
expect($src)->toContain('The Cacti Group');
});
@@ -18,8 +18,8 @@
*/
/*
* include/vendor/csrf/csrf-secret.php holds the secret every CSRF token is
* derived from. csrf_get_secret() only generates one when the file is missing
* include/vendor/csrf/csrf-secret.php holds the legacy secret pre-upgrade CSRF
* tokens are derived from. csrf_get_secret() only generates one when it is missing
* or empty, so a secret committed to the repository is never replaced: every
* install built from that tree shares it, and anyone who reads the repository
* can mint tokens for all of them.
@@ -65,32 +65,28 @@ test('the csrf secret is not tracked in the repository', function () {
test('no file under the csrf vendor directory holds a bare secret', function () {
$tracked = csrf_secret_git('ls-files include/vendor/csrf/');
expect($tracked['output'])->not->toBe('');
foreach (explode("\n", $tracked['output']) as $file) {
$contents = file_get_contents(dirname(__DIR__, 4) . '/' . $file);
expect($contents)->not->toBeFalse("$file must be readable");
// the secret is written as a bare 40 character hex digest, no php tags
expect(trim($contents))->not->toMatch('/^[0-9a-f]{40}$/');
}
/* The vendored csrf-magic fork was removed, so nothing under this path is
tracked any more. That is a stronger guarantee than scanning what is
there, and it also stops the directory returning with a secret in it. */
expect($tracked['output'])->toBe('');
});
test('the secret path is still ignored so it cannot be committed again', function () {
$gitignore = file_get_contents(dirname(__DIR__, 4) . '/.gitignore');
$gitignore = file_get_contents(CACTI_PATH_BASE . '/.gitignore');
expect($gitignore)->not->toBeFalse('.gitignore must be readable');
expect($gitignore)->toContain('include/vendor/csrf/csrf-secret.php');
});
/**
* Removing the file is only safe because csrf-magic.php writes a fresh secret
* the first time it finds none, so this pins the generation path rather than
* assuming it.
* Removing the file is only safe because csrf_get_secret() writes a fresh
* secret the first time it finds none, so this pins the generation path rather
* than assuming it. The function moved from the vendored fork into
* include/csrf.php when csrf-magic was retired; the assertions are unchanged.
*/
test('csrf-magic still generates and stores a secret when none exists', function () {
$source = file_get_contents(dirname(__DIR__, 4) . '/include/vendor/csrf/csrf-magic.php');
expect($source)->not->toBeFalse('csrf-magic.php must be readable');
test('the secret is still generated and stored when none exists', function () {
$source = file_get_contents(CACTI_PATH_INCLUDE . '/csrf.php');
expect($source)->not->toBeFalse('include/csrf.php must be readable');
$start = strpos($source, 'function csrf_get_secret(');
expect($start)->not->toBeFalse();
@@ -0,0 +1,58 @@
<?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 Symfony CSRF component backs CactiCsrfGuard. include/vendor is
* gitignored and resolved by Composer: a source checkout runs composer
* install, and the release build installs from the lock. Committing the tree
* would put a second, drifting copy of the component in the repository, so
* the index is asserted to stay clear of it.
*/
$root = dirname(__DIR__, 4);
test('the Symfony CSRF classes are autoloadable', function () {
expect(class_exists(Symfony\Component\Security\Csrf\CsrfTokenManager::class))->toBeTrue();
expect(class_exists(Symfony\Component\Security\Csrf\CsrfToken::class))->toBeTrue();
expect(class_exists(Symfony\Component\Security\Csrf\TokenStorage\NativeSessionTokenStorage::class))->toBeTrue();
expect(class_exists(Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator::class))->toBeTrue();
});
test('the Symfony CSRF package is resolved by Composer, not committed', function () use ($root) {
$disabled_functions = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions'))));
if (!function_exists('exec') || in_array('exec', $disabled_functions, true)) {
test()->markTestSkipped('exec is disabled, so Composer package tracking cannot be inspected');
}
if (!file_exists($root . '/.git')) {
test()->markTestSkipped('not a git checkout, so Composer package tracking cannot be inspected');
}
$tracked = [];
$status = 0;
exec('git -C ' . escapeshellarg($root) . ' ls-files include/vendor/symfony/security-csrf/', $tracked, $status);
if ($status !== 0) {
test()->markTestSkipped('git could not inspect Composer package tracking');
}
expect($tracked)->toBe([]);
});
test('composer.json declares the dependency', function () use ($root) {
$composer = json_decode(file_get_contents($root . '/composer.json'), true);
expect($composer['require'])->toHaveKey('symfony/security-csrf');
});
@@ -19,7 +19,7 @@ test('installer csrf failures return a scoped json recovery response', function
$jsonBranch = strpos($csrf, "defined('IN_CACTI_INSTALL')");
$redirect = strpos($csrf, "raise_message('csrf_timeout')");
$regenerate = strpos($csrf, 'session_regenerate_id();');
$regenerate = strpos($csrf, 'session_regenerate_id(true);');
$freshToken = strpos($csrf, "'csrfMagicToken' => csrf_get_tokens()");
expect($jsonBranch)->not->toBeFalse()
@@ -28,7 +28,7 @@ test('installer csrf failures return a scoped json recovery response', function
->and($regenerate < $freshToken)->toBeTrue()
->and($csrf)->toContain("isset(\$GLOBALS['auth_json'])")
->and($csrf)->toContain("!empty(\$GLOBALS['is_request_ajax'])")
->and($csrf)->toContain("http_response_code(403)")
->and($csrf)->toContain('http_response_code(403)')
->and($csrf)->toContain("'error' => 'csrf_timeout'")
->and($csrf)->toContain("'csrfMagicToken' => csrf_get_tokens()")
->and($csrf)->toContain("header('Cache-Control: no-store')")
+115
View File
@@ -0,0 +1,115 @@
<?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 |
+-------------------------------------------------------------------------+
*/
/*
* Exercises csrf_check() in its own process so include/csrf.php's control
* flow runs for real, exit() included, instead of being inferred from source
* text. The default $fatal=true path calls exit() on a failed check, which
* would kill the Pest process itself if invoked in-process; a subprocess is
* the only way to observe that path actually running.
*
* Constants and stubs are all guard-defined so this also tolerates running
* after a real include/global.php has already declared them, though in
* practice it always runs standalone.
*
* Modes, selected by argv[1]:
* get - non-POST request
* nonfatal-fail - POST with a bad token, csrf_check(false)
* fatal-fail - POST with a bad token, csrf_check() (default $fatal)
*/
$root = dirname(__DIR__, 2);
if (!defined('CACTI_PATH_INCLUDE')) {
define('CACTI_PATH_INCLUDE', $root . '/include');
}
if (!defined('CACTI_PATH_LIBRARY')) {
define('CACTI_PATH_LIBRARY', $root . '/lib');
}
if (!defined('CACTI_PATH_URL')) {
define('CACTI_PATH_URL', '/');
}
if (!defined('CACTI_WEB')) {
// Disabled-guard branch: csrf_guard() short-circuits on !CACTI_WEB before
// touching Symfony, so validate() always fails without a real token
// manager. That is exactly what a probe of the bad-token paths needs.
define('CACTI_WEB', false);
}
if (!defined('CACTI_CSRF_SECRET')) {
define('CACTI_CSRF_SECRET', '');
}
if (!defined('POLLER_VERBOSITY_DEBUG')) {
define('POLLER_VERBOSITY_DEBUG', 5);
}
require_once CACTI_PATH_INCLUDE . '/vendor/autoload.php';
if (!function_exists('__')) {
function __($text, ...$args) : string {
return ($args ? vsprintf($text, $args) : $text);
}
}
if (!function_exists('cacti_log')) {
function cacti_log($string, $output = false, $environ = 'CMDPHP', $level = 0) {
return false;
}
}
if (!function_exists('raise_message')) {
function raise_message($message_id, $message = '', $message_level = 0, $message_title = null) {
return true;
}
}
if (!function_exists('sanitize_uri')) {
function sanitize_uri($uri) {
return $uri;
}
}
$mode = ($argv[1] ?? '');
$_SERVER['REQUEST_URI'] = '/test';
if ($mode === 'get') {
$_SERVER['REQUEST_METHOD'] = 'GET';
} else {
$_SERVER['REQUEST_METHOD'] = 'POST';
$_POST = ['__csrf_magic' => 'not-a-real-token'];
}
session_start();
ob_start();
require_once CACTI_PATH_INCLUDE . '/csrf.php';
fwrite(STDERR, "before\n");
$result = match ($mode) {
'get' => csrf_check(),
'nonfatal-fail' => csrf_check(false),
'fatal-fail' => csrf_check(),
default => (function () {
throw new InvalidArgumentException('unknown mode');
})(),
};
fwrite(STDERR, 'result:' . var_export($result, true) . "\n");
fwrite(STDERR, "after\n");
+45
View File
@@ -0,0 +1,45 @@
<?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 |
+-------------------------------------------------------------------------+
*/
$root = dirname(__DIR__, 2);
$mode = ($argv[1] ?? '');
define('CACTI_PATH_INCLUDE', $root . '/include');
define('CACTI_PATH_LIBRARY', $root . '/lib');
define('CACTI_PATH_URL', '/');
define('CACTI_WEB', true);
define('CACTI_CSRF_SECRET', '');
define('POLLER_VERBOSITY_DEBUG', 5);
if ($mode === 'installer') {
define('IN_CACTI_INSTALL', 1);
}
function cacti_log($message, $output = false, $environ = 'CMDPHP', $level = 0) {
fwrite(STDERR, 'log:' . $message . PHP_EOL);
return false;
}
register_shutdown_function(function () {
fwrite(STDERR, 'status:' . http_response_code() . PHP_EOL);
});
/* Deliberately do not register Composer's autoloader: this probe exercises
* the missing/partial vendor-tree branch rather than Symfony itself. */
require_once CACTI_PATH_INCLUDE . '/csrf.php';
$guard = csrf_guard();
fwrite(STDERR, 'enabled:' . ($guard->isEnabled() ? 'true' : 'false') . PHP_EOL);
+52
View File
@@ -0,0 +1,52 @@
<?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 |
+-------------------------------------------------------------------------+
*/
$root = dirname(__DIR__, 2);
$directory = sys_get_temp_dir() . '/cacti-csrf-directory-' . bin2hex(random_bytes(8));
$file = $directory . '/csrf-secret.php';
$secret = '<?php $secret = "' . str_repeat('ab', 20) . '";' . PHP_EOL;
mkdir($directory, 0700);
file_put_contents($file, $secret);
define('CACTI_PATH_INCLUDE', $root . '/include');
define('CACTI_PATH_LIBRARY', $root . '/lib');
define('CACTI_PATH_URL', '/');
define('CACTI_WEB', true);
define('CACTI_CSRF_SECRET', $directory);
define('POLLER_VERBOSITY_DEBUG', 5);
function cacti_log($message, $output = false, $environ = 'CMDPHP', $level = 0) {
return false;
}
require_once CACTI_PATH_INCLUDE . '/vendor/autoload.php';
require_once CACTI_PATH_INCLUDE . '/csrf.php';
session_start();
$time = time();
$inner = hash_hmac('sha1', $time . ':' . session_id(), $secret);
$outer = hash_hmac('sha1', $inner, $secret);
$token = 'sid:' . $outer . ',' . $time;
try {
fwrite(STDERR, 'path:' . csrf_secret_file_path(CACTI_CSRF_SECRET) . PHP_EOL);
fwrite(STDERR, 'reader:' . (csrf_get_secret() === $secret ? 'true' : 'false') . PHP_EOL);
fwrite(STDERR, 'legacy:' . (csrf_guard()->validate($token) ? 'true' : 'false') . PHP_EOL);
} finally {
session_write_close();
unlink($file);
rmdir($directory);
}
+57
View File
@@ -0,0 +1,57 @@
<?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 |
+-------------------------------------------------------------------------+
*/
$root = dirname(__DIR__, 2);
define('CACTI_PATH_LIBRARY', $root . '/lib');
function is_resource_writable(string $path) : bool {
return is_writable(dirname($path));
}
function csrf_get_secret() : string {
return str_repeat('a', 64);
}
function csrf_secret_file_path(string $path) : string {
return (is_dir($path) ? rtrim($path, '/\\') . '/csrf-secret.php' : $path);
}
require_once $root . '/install/functions.php';
$directory = sys_get_temp_dir() . '/cacti-csrf-permissions-' . bin2hex(random_bytes(8));
$file = $directory . '/csrf-secret.php';
$old_umask = umask(0000);
mkdir($directory, 0700);
umask(0022);
try {
$created = install_create_csrf_secret($directory);
clearstatcache(true, $file);
$mode = (fileperms($file) & 0777);
fwrite(STDERR, 'created:' . ($created ? 'true' : 'false') . PHP_EOL);
fwrite(STDERR, 'mode:' . decoct($mode) . PHP_EOL);
} finally {
umask($old_umask);
if (file_exists($file)) {
unlink($file);
}
if (is_dir($directory)) {
rmdir($directory);
}
}
+18 -3
View File
@@ -15,10 +15,14 @@
$root = dirname(__DIR__, 2);
define('CACTI_PATH_INCLUDE', $root . '/include');
define('CACTI_PATH_LIBRARY', $root . '/lib');
define('CACTI_PATH_URL', '/');
define('CACTI_WEB', true);
define('CACTI_CSRF_SECRET', '');
define('IN_CACTI_INSTALL', 1);
define('POLLER_VERBOSITY_DEBUG', 5);
require_once CACTI_PATH_INCLUDE . '/vendor/autoload.php';
if (!function_exists('__')) {
function __($text, ...$args) {
@@ -26,18 +30,29 @@ if (!function_exists('__')) {
}
}
if (!function_exists('cacti_log')) {
function cacti_log($string, $output = false, $environ = 'CMDPHP', $level = 0) {
return false;
}
}
$auth_json = true;
$is_request_ajax = isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strcasecmp($_SERVER['HTTP_X_REQUESTED_WITH'], 'xmlhttprequest') === 0;
require_once CACTI_PATH_INCLUDE . '/vendor/csrf/csrf-conf.php';
$GLOBALS['csrf']['secret'] = 'issue-7343-integration-test-secret';
// The guard keeps its tokens in $_SESSION, and csrf_error_callback() rotates
// the session id before minting the replacement, so the session has to be
// live first. include/global.php calls cacti_session_start() the same way.
session_start();
// Match step_json.php's outer response buffer before CSRF validation runs.
ob_start();
require_once CACTI_PATH_INCLUDE . '/csrf.php';
// csrf-magic ran its check from the bottom of its own file; the guard is
// invoked explicitly, as include/global.php does.
csrf_startup();
// Reaching this point means csrf_check() accepted the follow-up token. The
// production endpoint would now construct and serialize the Installer.
$response = json_encode(
@@ -215,7 +215,7 @@ test('installer csrf timeout returns a replacement token accepted by the next re
->and($rejected['headers']['x-content-type-options'] ?? [])->toContain('nosniff')
->and($payload)->toBeArray()
->and($payload['error'] ?? null)->toBe('csrf_timeout')
->and($payload['csrfMagicToken'] ?? '')->toStartWith('sid:')
->and($payload['csrfMagicToken'] ?? '')->not->toBe('')
->and($cookie)->not->toBe('');
$accepted = issue7343Post(
@@ -228,7 +228,7 @@ test('installer csrf timeout returns a replacement token accepted by the next re
expect($accepted['status'])->toBe(200)
->and($acceptedPayload)->toBeArray()
->and($acceptedPayload['csrfValidated'] ?? false)->toBeTrue()
->and($acceptedPayload['csrfMagicToken'] ?? '')->toStartWith('sid:');
->and($acceptedPayload['csrfMagicToken'] ?? '')->not->toBe('');
} finally {
if (is_resource($server)) {
issue7343StopServer($server, $pipes);
@@ -149,7 +149,6 @@ fs_write ./cli/import_template.php $fp = fopen($filename,'r');
fs_write ./cli/input_whitelist.php $filesystem->writeFile($config['input_whitelist'], json_encode($input));
fs_write ./cli/md5sum.php $filesystem->writeFile($md5_file, $output);
fs_write ./cli/migrate_poller.php $result = file_put_contents($migration_log_file, date('Y-m-d H:i:s') . " - $message" . PHP_EOL, FILE_APPEND);
fs_write ./cli/refresh_csrf.php $filesystem->writeFile(CACTI_CSRF_SECRET, '<?php $secret = "' . $new_secret . '";' . PHP_EOL);
fs_write ./cli/rrdresize.php if (!file_put_contents($tmp_xml_file, $rrd_new_header . $rrd_new_body . '</rrd>')) {
fs_write ./cli/rrdresize.php $log_handle = fopen($tmp_backup_folder . 'tmp_rrdresize.log', 'w');
fs_write ./cli/splice_rrd.php if (($f = @fopen($path, 'a'))) {
@@ -157,11 +156,14 @@ fs_write ./cli/splice_rrd.php if (($f = @fopen($path, 'w'))) {
fs_write ./cli/splice_rrd.php return file_put_contents($xmlfile, $output);
fs_write ./cli/splice_rrd.php file_put_contents($newxmlfile, $new_xml);
fs_write ./host.php $stdout = fopen('php://output', 'w');
fs_write ./include/csrf.php $fh = fopen($file, 'w');
fs_write ./include/csrf.php $fh = @fopen($path, 'a');
fs_write ./include/csrf.php $fh = @fopen($path, 'w');
fs_write ./include/global_languages.php file_put_contents($config['i18n_log'], $text . $eol, $mode);
fs_write ./include/global_languages.php file_put_contents($config['i18n_text_log'], $text . $eol, $mode);
fs_write ./include/themes/midwinter/update_hash.php file_put_contents($cssFile, $fileUpdated);
fs_write ./install/functions.php $fp = fopen($config_file, 'w');
fs_write ./install/functions.php $fh = fopen($file, 'w');
fs_write ./install/functions.php $fh = fopen($file, 'w');
fs_write ./install/functions.php file_put_contents($cacheFile, '<[version]> ' . $cacti_upgrade_version . ' <[status]> ' . $status . ' <[sql]> ' . clean_up_lines($sql) . ' <[error]> ' . $database_last_error . PHP_EOL, FILE_APPEND);
fs_write ./install/functions.php file_put_contents(CACTI_PATH_LOG . '/' . $logfile . '.log', sprintf($format_log1, $day, $time, $levelname, $data, PHP_EOL), $flags);
fs_write ./install/functions.php file_put_contents(CACTI_PATH_LOG . '/install-complete.log', sprintf($format_log2, $day, $time, $sectionname, $levelname, $data, PHP_EOL), $flags);
Can't render this file because it contains an unexpected character in line 22 and column 251.
+1
View File
@@ -11,6 +11,7 @@ fi
EXCLUDE=(
--glob '!include/vendor/**'
--glob '!vendor/**'
--glob '!locales/**'
--glob '!tests/**'
--glob '!**/*.min.js'
-1
View File
@@ -25,7 +25,6 @@ function cacti_vendor_policy_legacy_paths() : array {
return [
'include/vendor/GoogleAuthenticator/',
'include/vendor/composer/index.php',
'include/vendor/csrf/',
'include/vendor/index.php',
'include/vendor/parsedown/',
'include/vendor/phpdiff/',