mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
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:
1 parent
cc7d14566c
commit
cb49f31ff9
37 files changed
+2236
-1206
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
Vendored
-126
@@ -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>.
|
||||
Vendored
-160
@@ -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.
|
||||
Vendored
-158
@@ -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'] = '';
|
||||
Vendored
-609
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
-25
@@ -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
@@ -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
@@ -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
@@ -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 . '"');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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.
|
@@ -11,6 +11,7 @@ fi
|
||||
|
||||
EXCLUDE=(
|
||||
--glob '!include/vendor/**'
|
||||
--glob '!vendor/**'
|
||||
--glob '!locales/**'
|
||||
--glob '!tests/**'
|
||||
--glob '!**/*.min.js'
|
||||
|
||||
@@ -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/',
|
||||
|
||||
Reference in new issue
Block a user