security: stage nonce CSP migration and gated enforcement (#7662)

* security: add nonce CSP mode for inline JavaScript protection

Reworks the content_security_policy_script setting into a three-mode Inline JavaScript Protection dropdown (HTMLX default, Nonce, None) and ports the per-request nonce, strict-dynamic policy, and CSP violation report endpoint from 1.2.x. The stored option key and its default are unchanged, so existing installs keep their current policy byte-for-byte; nonce mode is opt-in.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* security: stage nonce CSP enforcement safely

Add report-only migration, gated enforcement, nonce-aware shared emitters, Linux Docker validation, and operator documentation.\n\nRefs #7703

* security: validate CSP header sources

Reject malformed alternate-source configurations at the CSP boundary, validate fallback report URIs, and remove the obsolete Psalm header-taint baseline entry.\n\nRefs #7705

* chore: drop the composer.json change from this PR

Dependency and manifest changes are a maintainer call and do not belong in
this one.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-08-16 21:38:46 -04:00
1 parent ed09adfffd
commit d4e255b5d9
24 files changed
+1514 -47

No files matched your search

-2
View File
@@ -3,8 +3,6 @@
*.md
!README.md
LICENSE
.phpstan*
.php-cs-fixer.php
.pre-commit-config.yaml
.env
.env.local
+25
View File
@@ -0,0 +1,25 @@
<?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/ |
+-------------------------------------------------------------------------+
*/
require_once(__DIR__ . '/lib/csp_report_endpoint.php');
+27
View File
@@ -0,0 +1,27 @@
FROM cacti-web
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip \
&& pecl install xdebug \
&& docker-php-ext-enable xdebug \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
COPY . ./
RUN composer install --prefer-dist --no-progress --no-interaction \
&& mkdir -p cache log rra \
&& touch log/cacti.log \
&& chmod -R a+rwX cache log rra \
&& cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini \
&& git init \
&& git add --force . \
&& chown -R www-data:www-data /work
USER www-data
ENTRYPOINT ["php"]
+70
View File
@@ -0,0 +1,70 @@
# CSP Nonce Migration
Cacti's nonce Content Security Policy (CSP) has separate observation and
enforcement stages. Do not enable enforcement until the report-only violation
inventory is empty for the installed core, themes, and plugins.
## Enable report-only migration
1. Open **Console > Configuration > Settings > General**.
2. Expand **Site Security**.
3. Set **Inline JavaScript Protection** to
**Nonce Migration (Report Only)**.
4. Optionally set **CSP Violation Report URI**. Leaving it empty uses Cacti's
bundled `csp_report.php` endpoint.
5. Save, exercise the UI, and review CSP violation reports.
Report-only mode continues enforcing the compatible `unsafe-inline` policy. It
also sends a strict nonce policy through the
`Content-Security-Policy-Report-Only` header, so violations are observable
without breaking pages.
## Migrate violations
Every parser-inserted `<script>` element, including elements with `src`, must
carry the request nonce:
```php
<script type='text/javascript' <?php print CactiSecureHeaders::getNonceAttribute(); ?>>
```
Replace `onclick`, `onchange`, and other event attributes with event listeners
registered by a nonced script or a nonced external JavaScript file. Prefer
delegated listeners for elements replaced through AJAX.
Use this source inventory as a starting point:
```sh
rg -n -i --glob '*.php' --glob '*.html' \
'<script\b|\bon[a-z]+\s*=|javascript\s*:'
```
An empty text scan is necessary but not sufficient. Exercise authentication,
console and graph pages, every installed theme, and every enabled plugin while
collecting report-only violations.
## Enable enforcement
Enforcement has two independent gates so it cannot be activated by a settings
change alone.
First add this explicit opt-in to `include/config.php`:
```php
define('CACTI_CSP_NONCE_ENFORCE', true);
```
Then select **Nonce Enforcement (Advanced)** under **Inline JavaScript
Protection**. Without the constant, that selection safely behaves as
report-only migration.
After enabling enforcement, verify that responses contain a nonce-bearing
`Content-Security-Policy` header and do not contain a
`Content-Security-Policy-Report-Only` header. Keep monitoring reports and be
prepared to remove the constant if an installed plugin still emits legacy
script markup.
The enforcement policy currently retains `unsafe-eval` for jQuery
compatibility. Removing it is a separate migration: inventory `eval`,
`new Function`, string-based timers, and jQuery global evaluation paths before
tightening the policy.
+1 -1
View File
@@ -382,7 +382,7 @@ if (empty($_SESSION[SESS_USER_ID])) {
<div class='versionInfo'>" . __('Version') . ' ' . $version . ' | ' . COPYRIGHT_YEARS_SHORT . "</div>
</div>
<div class='logoutRight'></div>
<script type='text/javascript'>
<script type='text/javascript' " . CactiSecureHeaders::getNonceAttribute() . ">
$(function() {
$('.loginLeft').css('width',parseInt($(window).width()*0.33)+'px');
$('.loginRight').css('width',parseInt($(window).width()*0.33)+'px');
+12 -8
View File
@@ -287,6 +287,7 @@ if (isset($i18n_text_log)) {
require_once(CACTI_PATH_LIBRARY . '/database.php');
require_once(CACTI_PATH_LIBRARY . '/functions.php');
require_once(CACTI_PATH_LIBRARY . '/renderer.php');
require_once(CACTI_PATH_LIBRARY . '/headers_secure.php');
require_once(CACTI_PATH_INCLUDE . '/global_constants.php');
define('CACTI_VERSION', format_cacti_version($cacti_version, CACTI_VERSION_FORMAT_SHORT));
@@ -630,16 +631,19 @@ if ($config['is_web']) {
header('X-Frame-Options: SAMEORIGIN');
// increased web hardening
$script_policy = read_config_option('content_security_policy_script');
$alternates = CactiSecureHeaders::normalizeAlternateSources(read_config_option('content_security_alternate_sources'));
$script_src = CactiSecureHeaders::scriptSrc($alternates);
$report_uri = CactiSecureHeaders::getCspMode() === 'nonce-enforce' ?
CactiSecureHeaders::reportUriDirective() : '';
if ($script_policy == 'unsafe-eval') {
$script_policy = "'$script_policy'";
} else {
$script_policy = '';
header("Content-Security-Policy: default-src *; img-src 'self' https://api.qrserver.com $alternates data: blob:; style-src 'self' 'unsafe-inline' $alternates; $script_src; frame-ancestors 'self' $alternates; worker-src 'self' $alternates;$report_uri");
$report_script_src = CactiSecureHeaders::reportOnlyScriptSrc($alternates);
if ($report_script_src !== '') {
$report_uri = CactiSecureHeaders::reportUriDirective();
header("Content-Security-Policy-Report-Only: default-src *; img-src 'self' https://api.qrserver.com $alternates data: blob:; style-src 'self' 'unsafe-inline' $alternates; $report_script_src; frame-ancestors 'self' $alternates; worker-src 'self' $alternates;$report_uri");
}
$alternates = htmle(read_config_option('content_security_alternate_sources'));
header("Content-Security-Policy: default-src *; img-src 'self' https://api.qrserver.com $alternates data: blob:; style-src 'self' 'unsafe-inline' $alternates; script-src 'self' $script_policy 'unsafe-inline' $alternates; frame-ancestors 'self' $alternates; worker-src 'self' $alternates;");
// prevent IE from silently rejects cookies sent from third party sites.
header('P3P: CP="CAO PSA OUR"');
+1 -1
View File
@@ -121,7 +121,7 @@ if ($graphs) {
}
?>
<script type='text/javascript'>
<script type='text/javascript' <?php print CactiSecureHeaders::getNonceAttribute(); ?>>
var cactiVersion='<?php print $config['cacti_version']; ?>';
var cactiServerOS='<?php print CACTI_SERVER_OS; ?>';
var cactiAction='<?php print gfrv('action', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([-a-zA-Z0-9_\s]+)$/']]); ?>';
+14 -4
View File
@@ -734,14 +734,24 @@ $settings['general'] = [
],
'content_security_policy_script' => [
'method' => 'drop_array',
'friendly_name' => __('Content-Security Allow Unsafe JavaScript eval() calls'),
'description' => __('Certain Cacti plugins require the use of unsafe JavaScript eval() calls. If you select this option, they will be allowed in Cacti.'),
'friendly_name' => __('Inline JavaScript Protection'),
'description' => __('Controls how the Content-Security-Policy restricts inline JavaScript. HTMX keeps the \'unsafe-inline\' policy that all current themes and plugins rely on and matches the behaviour of prior releases. Nonce Migration emits a strict nonce policy in report-only mode so incompatible core and plugin scripts can be identified without breaking the UI. Nonce Enforcement blocks scripts without the request nonce and requires CACTI_CSP_NONCE_ENFORCE to be true in include/config.php; without that explicit gate it safely remains in report-only mode. None also permits unsafe eval() calls and is the least restrictive option.'),
'default' => '',
'array' => [
'0' => __('No'),
'unsafe-eval' => __('Yes')
'' => __('HTMX (Default)'),
'nonce' => __('Nonce Migration (Report Only)'),
'nonce-enforce' => __('Nonce Enforcement (Advanced)'),
'unsafe-eval' => __('None')
]
],
'content_security_report_uri' => [
'friendly_name' => __('CSP Violation Report URI'),
'description' => __('Used in Nonce Migration and Nonce Enforcement modes. URL where browsers post Content-Security-Policy violation reports. Leave blank to use the bundled csp_report.php handler under the configured Cacti URL path.'),
'method' => 'textbox',
'default' => '',
'size' => '100',
'max_length' => '255',
],
'content_security_alternate_sources' => [
'friendly_name' => __('Content-Security Alternate Sources'),
'description' => __('Space-delimited domain names that will be permitted to be accessed outside of the Web Server itself. This is important for users choosing to use a CDN, or hosting site. Sources can include wildcards for example: *.mydomain.com, or a protocol, for example: https://*.example.com. These Alternate Sources include Image, CSS and JavaScript types only.'),
+1 -1
View File
@@ -54,7 +54,7 @@ if (!$is_request_ajax) { ?>
<?php if (isset($user_menu) && is_array($user_menu)) {?>
<div style='display:none;' id='navigation' class='cactiConsoleNavigationArea'>
<?php draw_menu($user_menu); ?>
<div class='cactiLogo' onclick='loadUrl({url:"<?php print CACTI_PATH_URL; ?>about.php"})'></div>
<a class='cactiLogo' href='<?php print CACTI_PATH_URL; ?>about.php' aria-label='<?php print __esc('About Cacti'); ?>'></a>
</div>
<?php } ?>
<div id='navigation_right' class='cactiConsoleContentArea'>
+1 -1
View File
@@ -53,7 +53,7 @@ if (!$is_request_ajax) {?>
<div id='cactiContent' class='cactiContent'>
<div class='cactiConsoleNavigationArea' style='display:none;' id='navigation'>
<?php draw_menu(); ?>
<div class='cactiLogo' onclick='loadUrl({url:"<?php print CACTI_PATH_URL; ?>about.php"})'></div>
<a class='cactiLogo' href='<?php print CACTI_PATH_URL; ?>about.php' aria-label='<?php print __esc('About Cacti'); ?>'></a>
</div>
<div id='navigation_right' class='cactiConsoleContentArea'>
<main id='main'>
+1 -1
View File
@@ -4683,7 +4683,7 @@ function auth_display_custom_error_message(string $message) : void {
print '<div class="ui-state-error ui-corner-all" style="width:50%;margin-left:auto;margin-right:auto;margin-top:200px;padding:20px"><p>' . $message . '</p><p>' . $custom_message . '</p></div>';
if ($auth_method != AUTH_METHOD_BASIC) {
print '<div class="ui-corner-all" style="width:50%;margin:auto;padding:20px"><a href="index.php">' . __('Login Again') . '</a></div><script type="text/javascript">$(function() { $("a").button(); });</script>';
print '<div class="ui-corner-all" style="width:50%;margin:auto;padding:20px"><a href="index.php">' . __('Login Again') . '</a></div><script type="text/javascript" ' . CactiSecureHeaders::getNonceAttribute() . '>$(function() { $("a").button(); });</script>';
}
print '</center></body></html>';
+189
View File
@@ -0,0 +1,189 @@
<?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/ |
+-------------------------------------------------------------------------+
*/
/* This endpoint is unauthenticated and reachable pre-bootstrap, so it
* deliberately avoids loading lib/functions.php or include/global.php.
* Loading functions.php alone surfaced undefined-$config warnings in
* targeted PHP checks. csp_report_log() below writes to PHP's default
* error log, falling through to cacti_log() only if a parent caller
* already set up $config and that function is available. */
/**
* Self-contained logger for the CSP report endpoint. Prefers cacti_log()
* when a parent caller has already bootstrapped $config; otherwise writes
* to PHP's default error log. Either way the report lands somewhere an
* operator can read it without depending on the Cacti DB.
*
* @param mixed $message
*/
function csp_report_log($message): void {
$message = preg_replace('/[\x00-\x1f\x7f]/', ' ', (string) $message);
if (function_exists('cacti_log') && isset($GLOBALS['config']['base_path'])) {
@cacti_log($message, false, 'CSP-REPORT');
return;
}
error_log('CACTI CSP-REPORT: ' . $message);
}
/**
* Strip log-injection characters from a CSP report field before interpolation.
* A crafted report body with embedded CR/LF would forge extra log lines.
*
* @param mixed $v
*/
function csp_report_sanitize_field($v): string {
if (!is_string($v)) {
return '(unknown)';
}
// Strip CR/LF and other C0 controls; truncate to 256 chars for log sanity.
$v = preg_replace('/[\x00-\x1f\x7f]/', ' ', $v);
if (strlen($v) > 256) {
$v = substr($v, 0, 253) . '...';
}
return $v;
}
/**
* Validate and normalise a raw CSP report POST.
*
* Kept as a pure function so it can be exercised in unit tests without
* an actual HTTP request context. Returns a result array rather than
* throwing so the caller controls the HTTP response code.
*
* @param array $headers Associative array of request headers (CONTENT_TYPE key).
* @param string $body Raw POST body.
* @param int $maxBytes Upper bound enforced before JSON parse; 16 KB is
* large enough for any real report but small enough
* to prevent the process from buffering an attacker-
* supplied multi-megabyte payload into memory.
* @return array ['ok' => bool, 'reason' => string, 'summary' => string]
*/
function csp_report_validate_payload(array $headers, $body, $maxBytes) {
$ct = isset($headers['CONTENT_TYPE']) ? strtolower(trim($headers['CONTENT_TYPE'])) : '';
if (strpos($ct, 'application/csp-report') !== 0 && strpos($ct, 'application/json') !== 0) {
return ['ok' => false, 'reason' => 'Unsupported Content-Type', 'summary' => ''];
}
$len = strlen($body);
if ($len === 0) {
return ['ok' => false, 'reason' => 'Empty body', 'summary' => ''];
}
if ($len > $maxBytes) {
return ['ok' => false, 'reason' => 'Body exceeds size limit', 'summary' => ''];
}
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['ok' => false, 'reason' => 'Invalid JSON', 'summary' => ''];
}
if (!is_array($decoded)) {
return ['ok' => false, 'reason' => 'JSON root must be an object', 'summary' => ''];
}
/* Browsers send either the legacy format:
* {"csp-report": {"violated-directive": ..., ...}}
* or the Reporting API (report-to) format:
* {"type": "csp-violation", "body": {"effectiveDirective": ..., ...}}
* Normalise both into a flat map of the violation fields. */
if (isset($decoded['csp-report']) && is_array($decoded['csp-report'])) {
$report = $decoded['csp-report'];
// Sanitize before interpolation: a crafted report can embed CR/LF to forge log lines.
$directive = csp_report_sanitize_field(isset($report['violated-directive']) ? $report['violated-directive'] : '(unknown)');
$blockedUri = csp_report_sanitize_field(isset($report['blocked-uri']) ? $report['blocked-uri'] : '(unknown)');
$docUri = csp_report_sanitize_field(isset($report['document-uri']) ? $report['document-uri'] : '(unknown)');
} elseif (isset($decoded['body']) && is_array($decoded['body'])) {
$report = $decoded['body'];
// report-to uses camelCase keys; same log-injection risk applies.
$directive = csp_report_sanitize_field(isset($report['effectiveDirective']) ? $report['effectiveDirective'] : '(unknown)');
$blockedUri = csp_report_sanitize_field(isset($report['blockedURL']) ? $report['blockedURL'] : '(unknown)');
$docUri = csp_report_sanitize_field(isset($report['documentURL']) ? $report['documentURL'] : '(unknown)');
} else {
return ['ok' => false, 'reason' => 'Missing csp-report or body field', 'summary' => ''];
}
$summary = 'CSP violation: ' . $directive . ' blocked ' . $blockedUri . ' on ' . $docUri;
return ['ok' => true, 'reason' => '', 'summary' => $summary];
}
// --- Entry point ----------------------------------------------------------
if (defined('CACTI_CSP_REPORT_TEST_MODE')) {
return;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}
/* $_SERVER['CONTENT_TYPE'] is set by most SAPI implementations; the
* HTTP_CONTENT_TYPE fallback covers CGI setups that omit the prefix. */
$contentType = '';
if (isset($_SERVER['CONTENT_TYPE'])) {
$contentType = $_SERVER['CONTENT_TYPE'];
} elseif (isset($_SERVER['HTTP_CONTENT_TYPE'])) {
$contentType = $_SERVER['HTTP_CONTENT_TYPE'];
}
/* Hard-cap the read at the stream level so a large body from this unauthenticated
* endpoint is never buffered whole. stream_get_contents() with a maxlength stops
* reading after 16 KB regardless of SAPI quirks around php://input. */
$rawBody = '';
$inputStream = fopen('php://input', 'rb');
if ($inputStream !== false) {
$rawBody = (string) stream_get_contents($inputStream, 16384);
fclose($inputStream);
}
$result = csp_report_validate_payload(
['CONTENT_TYPE' => $contentType],
$rawBody,
16384
);
if ($result['ok']) {
csp_report_log($result['summary']);
http_response_code(204);
} else {
http_response_code(400);
header('Content-Type: text/plain; charset=UTF-8');
/* Emit the rejection reason as plain text. No HTML output here because
* this endpoint is called by the browser's report mechanism, not rendered
* in a page, and we don't want to introduce XSS surface. */
print $result['reason'];
}
+2 -2
View File
@@ -1220,7 +1220,7 @@ function raise_message(mixed $message_id, string $message = '', int $message_lev
*/
function raise_message_javascript(string $title, string $header, string $message, int $level = MESSAGE_LEVEL_MIXED) : void {
?>
<script type='text/javascript'>
<script type='text/javascript' <?php print CactiSecureHeaders::getNonceAttribute(); ?>>
var mixedReasonTitle = DOMPurify.sanitize(<?php print json_encode($title, JSON_THROW_ON_ERROR); ?>);
var mixedOnPage = DOMPurify.sanitize(<?php print json_encode($header, JSON_THROW_ON_ERROR); ?>);
var message = DOMPurify.sanitize(<?php print json_encode($message, JSON_THROW_ON_ERROR); ?>);
@@ -8623,7 +8623,7 @@ function get_theme_paths(string $format, string $path, string|null $theme = null
* @return string
*/
function get_md5_include_js(string $path, bool $async = false, string|null $theme = null, string|null $file = null) : string {
$format = '<script type=\'text/javascript\' src=\'%s\'%s></script>';
$format = '<script type=\'text/javascript\' src=\'%s\'%s ' . CactiSecureHeaders::getNonceAttribute() . '></script>';
return get_theme_paths($format, $path, $theme, $file, true, $async ? ' async' : '');
}
+405
View File
@@ -0,0 +1,405 @@
<?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/ |
+-------------------------------------------------------------------------+
*/
/**
* Content-Security-Policy script-src helper.
*
* The `content_security_policy_script` setting selects how the CSP restricts
* inline JavaScript:
*
* '' HTMX (default) - keeps 'unsafe-inline', the policy every
* current theme and plugin relies on. Behaviour is identical
* to releases before this helper existed.
* 'nonce' Emits a per-request nonce plus 'strict-dynamic' in a
* report-only policy. Cacti-owned inline <script> tags can
* adopt the nonce incrementally without breaking legacy pages.
* 'nonce-enforce' Enforces the nonce policy. This mode is additionally
* gated by CACTI_CSP_NONCE_ENFORCE so selecting it before the
* migration is complete cannot unexpectedly blank the UI.
* 'unsafe-eval' None - most permissive, also allows eval(). This is the
* legacy value, kept so upgrades that already selected it are
* not silently rewritten.
*
* Nonce migration keeps the enforced policy byte-compatible and emits the
* stricter script-src in a separate report-only header. The rest of each policy
* is built at the call sites. The buildScriptSrc()/buildReportUriDirective()
* methods are pure so mode branching can be tested without request state.
*/
class CactiSecureHeaders {
/**
* Per-request nonce. 18 random bytes base64url-encoded (RFC 4648 sec 5)
* give 24 chars with no padding and none of the '+' '/' '=' characters
* that are unsafe unquoted in a CSP source token.
*/
public static function getNonce(): string {
static $nonce = null;
if ($nonce !== null) {
return $nonce;
}
try {
$bytes = random_bytes(18);
} catch (\Throwable $e) {
if (function_exists('cacti_log')) {
cacti_log('CSP nonce generation failed: ' . $e->getMessage(), false, 'SYSTEM');
}
throw new \RuntimeException('Unable to generate a cryptographically secure CSP nonce.', 0, $e);
}
$nonce = rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
return $nonce;
}
/**
* `nonce="..."` attribute for inline <script> tags. Returns an empty string
* outside nonce mode so the attribute is only emitted when it matters.
*/
public static function getNonceAttribute(): string {
$mode = self::getCspMode();
$nonce = self::isNonceModeValue($mode) ? self::getNonce() : '';
return self::buildNonceAttribute($mode, $nonce);
}
/**
* Build a nonce attribute for a normalized policy mode.
*/
public static function buildNonceAttribute(string $mode, string $nonce): string {
if (!self::isNonceModeValue($mode)) {
return '';
}
if (!self::isValidNonce($nonce)) {
throw new \InvalidArgumentException('Invalid CSP nonce.');
}
return 'nonce="' . $nonce . '"';
}
/**
* Normalise the stored setting to one of the four known tokens. Unknown
* or legacy values fall back to the default (unsafe-inline) rather than a
* stricter policy, so a bad option value never blanks the UI.
*/
public static function getCspMode(): string {
if (!function_exists('read_config_option')) {
return '';
}
$mode = self::normalizeCspMode(read_config_option('content_security_policy_script'));
if ($mode === 'nonce-enforce' &&
(!defined('CACTI_CSP_NONCE_ENFORCE') || CACTI_CSP_NONCE_ENFORCE !== true)) {
return 'nonce';
}
return $mode;
}
/**
* Normalize the stored setting without requiring a configuration context.
*/
public static function normalizeCspMode(mixed $value): string {
if ($value === 'nonce' || $value === 'nonce-enforce' || $value === 'unsafe-eval') {
return $value;
}
return '';
}
/**
* Normalize the configured space-delimited CSP host sources. Reject the
* complete value when any token is malformed so an attempted directive or
* header injection cannot leave behind a partially valid, broader policy.
*
* @psalm-taint-escape header
* @psalm-taint-escape html
*/
public static function normalizeAlternateSources(mixed $value): string {
if (!is_string($value) && !is_numeric($value)) {
return '';
}
$value = trim((string) $value);
if ($value === '' || strlen($value) > 2048) {
return '';
}
$sources = preg_split('/\s+/', $value, -1, PREG_SPLIT_NO_EMPTY);
if ($sources === false) {
return '';
}
$normalized = [];
foreach ($sources as $source) {
if (!self::isValidAlternateSource($source)) {
return '';
}
$normalized[$source] = true;
}
return implode(' ', array_keys($normalized));
}
public static function isNonceMode(): bool {
return self::isNonceModeValue(self::getCspMode());
}
/**
* script-src directive for the enforced policy. Nonce migration deliberately
* retains the compatible default here; reportOnlyScriptSrc() builds the
* strict policy used to inventory remaining violations.
*/
public static function scriptSrc(string $alternates): string {
$mode = self::getCspMode();
$nonce = $mode === 'nonce-enforce' ? self::getNonce() : '';
return self::buildEnforcedScriptSrc($mode, $alternates, $nonce);
}
/**
* Build the enforced script-src for a normalized mode.
*/
public static function buildEnforcedScriptSrc(string $mode, string $alternates, string $nonce = ''): string {
// Nonce mode remains report-only until every core and plugin script is
// nonce-aware. Keep the enforced policy compatible in the meantime.
if ($mode === 'nonce') {
$mode = '';
}
if ($mode === 'nonce-enforce') {
$mode = 'nonce';
}
return self::buildScriptSrc($mode, $nonce, $alternates);
}
/**
* Strict script-src used by the report-only migration policy.
*/
public static function reportOnlyScriptSrc(string $alternates): string {
$mode = self::getCspMode();
$nonce = $mode === 'nonce' ? self::getNonce() : '';
return self::buildReportOnlyScriptSrc($mode, $nonce, $alternates);
}
/**
* Build the strict migration script-src for a normalized mode.
*/
public static function buildReportOnlyScriptSrc(string $mode, string $nonce, string $alternates): string {
if ($mode !== 'nonce') {
return '';
}
return self::buildScriptSrc('nonce', $nonce, $alternates);
}
/**
* Pure builder for the script-src directive.
*
* 'strict-dynamic' lets a nonced script transitively trust scripts it
* injects (jQuery .html()/.append()), which supporting browsers require;
* those browsers ignore 'self'/'unsafe-inline' once it is present. Older
* browsers ignore 'strict-dynamic'/nonce and fall back to 'unsafe-inline'.
* 'unsafe-eval' covers jQuery globalEval() and new Function().
*
* @param string $mode '' | 'nonce' | 'unsafe-eval'
* @param string $nonce base64url nonce; used only in nonce mode
* @param string $alternates sanitized alternate-source token string
*/
public static function buildScriptSrc(string $mode, string $nonce, string $alternates): string {
$alternates = self::normalizeAlternateSources($alternates);
if ($mode === 'nonce') {
if (!self::isValidNonce($nonce)) {
throw new \InvalidArgumentException('Invalid CSP nonce.');
}
return "script-src 'self' 'nonce-$nonce' 'strict-dynamic' 'unsafe-eval' 'unsafe-inline' $alternates";
}
if ($mode === 'unsafe-eval') {
return "script-src 'self' 'unsafe-eval' 'unsafe-inline' $alternates";
}
return "script-src 'self' 'unsafe-inline' $alternates";
}
/**
* ` report-uri <uri>;` suffix for the CSP header, or '' when not applicable.
* report-uri only affects nonce mode and is invalid inside a <meta> policy,
* so only the HTTP-header call site appends it.
*/
public static function reportUriDirective(): string {
return self::buildReportUriDirective(self::getCspMode(), self::reportUri());
}
/**
* Pure builder for the report-uri suffix.
*
* @param string $mode '' | 'nonce' | 'unsafe-eval'
* @param string $uri already-validated report URI
*/
public static function buildReportUriDirective(string $mode, string $uri): string {
if (!self::isNonceModeValue($mode) || $uri === '') {
return '';
}
if (!self::isValidReportUri($uri)) {
throw new \InvalidArgumentException('Invalid CSP report URI.');
}
return " report-uri $uri;";
}
/**
* Validate a CSP nonce source value before it reaches a policy.
*/
private static function isValidNonce(string $nonce): bool {
return preg_match('/^[A-Za-z0-9_-]{24}$/D', $nonce) === 1;
}
/**
* Both migration and enforcement modes issue nonces to trusted scripts.
*/
private static function isNonceModeValue(string $mode): bool {
return $mode === 'nonce' || $mode === 'nonce-enforce';
}
/**
* Accept an absolute HTTP(S) URL or a root-relative endpoint without a
* query, fragment, authority credentials, whitespace, or control bytes.
*/
private static function isValidReportUri(string $uri): bool {
if ($uri === '' || preg_match('/[\x00-\x20\x7f;"\'\\\\]/', $uri)) {
return false;
}
if (str_starts_with($uri, '/')) {
return !str_starts_with($uri, '//') &&
!str_contains($uri, '?') &&
!str_contains($uri, '#');
}
$parts = parse_url($uri);
return is_array($parts) &&
filter_var($uri, FILTER_VALIDATE_URL) !== false &&
isset($parts['scheme'], $parts['host']) &&
in_array(strtolower($parts['scheme']), ['http', 'https'], true) &&
!isset($parts['user']) &&
!isset($parts['pass']) &&
!isset($parts['query']) &&
!isset($parts['fragment']);
}
/**
* Accept CSP host-source expressions supported by the alternate-source
* setting: optional HTTP/WebSocket scheme, hostname/IP with optional
* wildcard, optional port, and an optional path without query or fragment.
*/
private static function isValidAlternateSource(string $source): bool {
if ($source === '' || strlen($source) > 255) {
return false;
}
$pattern = '~\A(?:(?:https?|wss?)://)?(?<host>\*|\*\.[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(?::(?<port>\*|[0-9]{1,5}))?(?:/[A-Za-z0-9._\~!$&()*+,:=@%/-]*)?\z~Di';
if (preg_match($pattern, $source, $matches) !== 1) {
return false;
}
$host = $matches['host'];
if ($host !== '*') {
if (str_starts_with($host, '[')) {
$address = substr($host, 1, -1);
if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
return false;
}
} else {
$hostname = str_starts_with($host, '*.') ? substr($host, 2) : $host;
if (filter_var($hostname, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false &&
filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
return false;
}
}
}
$port_value = $matches['port'] ?? '';
if ($port_value !== '' && $port_value !== '*') {
$port = (int) $port_value;
if ($port < 1 || $port > 65535) {
return false;
}
}
return preg_match('/%(?![A-Fa-f0-9]{2})/', $source) !== 1;
}
/**
* Resolve the violation report endpoint. Prefers the configured value,
* rejecting anything that could break the header line, and otherwise
* derives the bundled csp_report.php path from the install's URL path.
*/
private static function reportUri(): string {
$configured = '';
if (function_exists('read_config_option')) {
$configured = read_config_option('content_security_report_uri');
}
$base = defined('CACTI_PATH_URL') ? rtrim(CACTI_PATH_URL, '/') : '';
return self::resolveReportUri($configured, $base);
}
/**
* Resolve a configured report endpoint against the Cacti URL base.
*
* @psalm-taint-escape header
*/
public static function resolveReportUri(mixed $configured, string $base): string {
if ($configured !== null && $configured !== false && $configured !== '' &&
self::isValidReportUri((string) $configured)) {
return (string) $configured;
}
$fallback = rtrim($base, '/') . '/csp_report.php';
return self::isValidReportUri($fallback) ? $fallback : '/csp_report.php';
}
}
+8 -14
View File
@@ -335,7 +335,7 @@ function html_graph_area(array &$graph_array, string $no_graphs_message = '', st
}
?>
<script type='text/javascript'>
<script type='text/javascript' <?php print CactiSecureHeaders::getNonceAttribute(); ?>>
if ($('#predefined_timespan').val() == 0) {
refreshMSeconds = 999999;
} else {
@@ -419,7 +419,7 @@ function html_graph_thumbnail_area(array &$graph_array, string $no_graphs_messag
}
?>
<script type='text/javascript'>
<script type='text/javascript' <?php print CactiSecureHeaders::getNonceAttribute(); ?>>
if ($('#predefined_timespan').val() == 0) {
refreshMSeconds = 999999;
} else {
@@ -663,7 +663,7 @@ function html_nav_bar(string $base_url, int $max_pages, int $current_page, int $
}
$url = $base_url . $page_var;
$nav .= "<script type='text/javascript'>
$nav .= "<script type='text/javascript' " . CactiSecureHeaders::getNonceAttribute() . ">
function goto$page_var(pageNo) {
if (typeof url_graph === 'function') {
var url_add=url_graph('')
@@ -2043,7 +2043,7 @@ function draw_actions_dropdown(array $actions_array, int $delete_action = 1) : v
</div>
</div>
<input type='hidden' id='action' name='action' value='actions' form='<?php print $form_id; ?>'/>
<script type='text/javascript'>
<script type='text/javascript' <?php print CactiSecureHeaders::getNonceAttribute(); ?>>
function setDisabled() {
$('tr[id^="line"]').addClass('selectable').prop('disabled', false).removeClass('disabled_row').unbind('click').prop('disabled', false);
@@ -3033,7 +3033,7 @@ function html_spikekill_menu(int $local_graph_id) : void {
function html_spikekill_js() : void {
?>
<script type='text/javascript'>
<script type='text/javascript' <?php print CactiSecureHeaders::getNonceAttribute(); ?>>
var spikeKillOpen = false;
$(function() {
@@ -3201,14 +3201,8 @@ function html_common_header(string $title, string $selectedTheme = '') : void {
print "<meta content='width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5' name='viewport'>" . PHP_EOL;
$script_policy = read_config_option('content_security_policy_script');
if ($script_policy == 'unsafe-eval') {
$script_policy = "'$script_policy'";
} else {
$script_policy = '';
}
$alternates = htmle(read_config_option('content_security_alternate_sources'));
$script_src = CactiSecureHeaders::scriptSrc($alternates);
?>
<meta http-equiv='X-UA-Compatible' content='IE=Edge,chrome=1'>
@@ -3216,13 +3210,13 @@ function html_common_header(string $title, string $selectedTheme = '') : void {
<meta name='description' content='Monitoringauth tool of the Internet'>
<meta name='mobile-web-app-capable' content='yes'>
<meta name="theme-color" content="#161616"/>
<meta http-equiv="Content-Security-Policy" content="default-src *; img-src 'self' https://api.qrserver.com <?php print $alternates; ?> data: blob:; style-src 'self' 'unsafe-inline' <?php print $alternates; ?>; script-src 'self' <?php print htmle($script_policy); ?> 'unsafe-inline' <?php print $alternates; ?>; worker-src 'self' <?php print $alternates; ?>;">
<meta http-equiv="Content-Security-Policy" content="default-src *; img-src 'self' https://api.qrserver.com <?php print $alternates; ?> data: blob:; style-src 'self' 'unsafe-inline' <?php print $alternates; ?>; <?php print $script_src; ?>; worker-src 'self' <?php print $alternates; ?>;">
<title><?php print $title; ?></title>
<meta http-equiv='Content-Type' content='text/html;charset=utf-8'>
<link rel='manifest' href='/manifest.json'>
<script type='text/javascript'>
<script type='text/javascript' <?php print CactiSecureHeaders::getNonceAttribute(); ?>>
var urlPath='<?php print CACTI_PATH_URL; ?>';
var aboutCacti = '<?php print __esc('About Cacti'); ?>';
var cactiCharts = '<?php print __esc('Charts'); ?>';
+8 -5
View File
@@ -20,7 +20,9 @@
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
*/
require_once __DIR__ . '/headers_secure.php';
/**
* Returns true when htmx support is enabled for this install. Reads the
@@ -155,8 +157,9 @@ function htmx_script_tag(): string {
// CACTI_PATH_URL is defined by include/global_path.php during a normal
// bootstrap. Fall back to '/' when the constant is absent (lightweight
// fixtures that load lib/htmx.php without the full path bootstrap).
$base = defined('CACTI_PATH_URL') ? CACTI_PATH_URL : '/';
$url = $base . 'include/js/htmx.js?' . $md5;
$base = defined('CACTI_PATH_URL') ? CACTI_PATH_URL : '/';
$url = $base . 'include/js/htmx.js?' . $md5;
$nonce_attr = CactiSecureHeaders::getNonceAttribute();
$config_meta = "<meta name='htmx-config' content='"
. htmlspecialchars('{"allowEval":false,"allowScriptTags":false}', ENT_QUOTES, 'UTF-8')
@@ -168,7 +171,7 @@ function htmx_script_tag(): string {
// __csrf_magic field that csrf-magic validates. Only body-based verbs get
// the token: htmx puts GET and DELETE parameters into the URL, which would
// leak it (see the function doc).
$csrf_wiring = "<script type='text/javascript'>\n"
$csrf_wiring = "<script type='text/javascript' $nonce_attr>\n"
. "document.addEventListener('htmx:configRequest', function(evt) {\n"
. "\tvar verb = String(evt.detail.verb).toLowerCase();\n"
. "\tif (typeof csrfMagicToken !== 'undefined' && (verb === 'post' || verb === 'put' || verb === 'patch')) {\n"
@@ -177,7 +180,7 @@ function htmx_script_tag(): string {
. "});\n"
. "</script>\n";
$tag = "<script type='text/javascript' src='" . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . "'"
$tag = "<script type='text/javascript' $nonce_attr src='" . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . "'"
. " integrity='" . HTMX_2_0_6_SRI . "'"
. " crossorigin='anonymous'></script>\n";
-5
View File
@@ -5,11 +5,6 @@
<code><![CDATA['Location: ' . sanitize_uri($_SERVER['REQUEST_URI'])]]></code>
</TaintedHeader>
</file>
<file src="include/global.php">
<TaintedHeader>
<code><![CDATA["Content-Security-Policy: default-src *; img-src 'self' https://api.qrserver.com $alternates data: blob:; style-src 'self' 'unsafe-inline' $alternates; script-src 'self' $script_policy 'unsafe-inline' $alternates; frame-ancestors 'self' $alternates; worker-src 'self' $alternates;"]]></code>
</TaintedHeader>
</file>
<file src="include/global_session.php">
<TaintedHtml>
<code><![CDATA[gfrv('action', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([-a-zA-Z0-9_\s]+)$/']])]]></code>
+94
View File
@@ -0,0 +1,94 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/* Prevent the entry-point block from executing when the file is included
* during testing. The guard in csp_report_endpoint.php checks for this
* constant and returns before touching $_SERVER or php://input. */
define('CACTI_CSP_REPORT_TEST_MODE', 1);
if (!function_exists('cacti_log')) {
function cacti_log($message, $stdout = false, $facility = 'CACTI') {
/* intentional no-op in test context */
}
}
require_once __DIR__ . '/../../lib/csp_report_endpoint.php';
function _csp_headers($ct) {
return array('CONTENT_TYPE' => $ct);
}
test('validator rejects oversize body', function () {
$body = str_repeat('a', 20000);
$result = csp_report_validate_payload(_csp_headers('application/csp-report'), $body, 16384);
expect($result['ok'])->toBeFalse();
expect(strtolower($result['reason']))->toContain('size');
});
test('validator rejects wrong content type', function () {
$result = csp_report_validate_payload(_csp_headers('text/plain'), '{"csp-report":{}}', 16384);
expect($result['ok'])->toBeFalse();
expect(strtolower($result['reason']))->toContain('content-type');
});
test('validator rejects malformed JSON', function () {
$result = csp_report_validate_payload(_csp_headers('application/csp-report'), 'not json{', 16384);
expect($result['ok'])->toBeFalse();
expect(strtolower($result['reason']))->toContain('json');
});
test('validator accepts legacy csp-report format', function () {
$body = json_encode(array(
'csp-report' => array(
'violated-directive' => 'script-src',
'blocked-uri' => 'inline',
'document-uri' => 'https://x/y',
),
));
$result = csp_report_validate_payload(_csp_headers('application/csp-report'), $body, 16384);
expect($result['ok'])->toBeTrue();
expect($result['summary'])->toContain('script-src');
expect($result['summary'])->toContain('inline');
expect($result['summary'])->toContain('https://x/y');
});
test('validator accepts Reporting API body format', function () {
$body = json_encode(array(
'type' => 'csp-violation',
'body' => array(
'effectiveDirective' => 'script-src',
'blockedURL' => 'https://evil.example/x.js',
'documentURL' => 'https://app.example/page',
),
));
$result = csp_report_validate_payload(_csp_headers('application/json'), $body, 16384);
expect($result['ok'])->toBeTrue();
expect($result['summary'])->toContain('script-src');
});
test('sanitizer strips CR/LF so a report cannot forge log lines', function () {
$dirty = "script-src\r\nFORGED LOG LINE";
$clean = csp_report_sanitize_field($dirty);
expect($clean)->not->toContain("\r");
expect($clean)->not->toContain("\n");
});
test('sanitizer truncates overlong fields', function () {
$clean = csp_report_sanitize_field(str_repeat('a', 500));
expect(strlen($clean))->toBeLessThanOrEqual(256);
});
+242
View File
@@ -0,0 +1,242 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* Tests for CactiSecureHeaders, the CSP script-src helper behind the
* "Inline JavaScript Protection" setting. The buildScriptSrc()/
* buildReportUriDirective() methods are pure, so mode branching is exercised
* directly without a config or request context.
*/
require_once dirname(__DIR__, 2) . '/lib/headers_secure.php';
// --- default (HTMX) mode: byte-identical to prior releases ---
test('default mode keeps unsafe-inline and no eval', function () {
$src = CactiSecureHeaders::buildScriptSrc('', 'IGNORED', '*.cdn.example');
expect($src)->toBe("script-src 'self' 'unsafe-inline' *.cdn.example");
expect($src)->not->toContain('nonce-');
expect($src)->not->toContain('unsafe-eval');
});
test('stored CSP modes are normalized to supported values', function () {
expect(CactiSecureHeaders::normalizeCspMode('nonce'))->toBe('nonce');
expect(CactiSecureHeaders::normalizeCspMode('nonce-enforce'))->toBe('nonce-enforce');
expect(CactiSecureHeaders::normalizeCspMode('unsafe-eval'))->toBe('unsafe-eval');
expect(CactiSecureHeaders::normalizeCspMode('unknown'))->toBe('');
expect(CactiSecureHeaders::normalizeCspMode(false))->toBe('');
});
test('alternate CSP sources are normalized from supported host expressions', function () {
$sources = " *.cdn.example\thttps://assets.example:8443/static/\n"
. 'wss://socket.example [2001:db8::1]:443 *.cdn.example 192.0.2.10:* '
. 'https://static.example/assets/app.js';
expect(CactiSecureHeaders::normalizeAlternateSources($sources))
->toBe('*.cdn.example https://assets.example:8443/static/ wss://socket.example [2001:db8::1]:443 192.0.2.10:* https://static.example/assets/app.js');
});
test('alternate CSP sources fail closed when any token is unsafe', function (string $sources) {
expect(CactiSecureHeaders::normalizeAlternateSources($sources))->toBe('');
})->with([
'directive injection' => 'https://cdn.example; script-src *',
'header injection' => "https://cdn.example\r\nX-Test: injected",
'quoted source' => 'https://cdn.example" unsafe.example',
'query string' => 'https://cdn.example/file.js?token=secret',
'fragment' => 'https://cdn.example/file.js#fragment',
'invalid percent' => 'https://cdn.example/%GG',
'invalid port' => 'https://cdn.example:65536',
'invalid hostname' => 'https://not_a_host.example',
]);
test('script source builders revalidate alternate sources', function () {
$src = CactiSecureHeaders::buildScriptSrc('', 'IGNORED', 'https://cdn.example; script-src *');
expect($src)->toBe("script-src 'self' 'unsafe-inline' ");
});
test('nonce migration keeps the enforced policy compatible', function () {
$src = CactiSecureHeaders::buildEnforcedScriptSrc('nonce', '*.cdn.example');
expect($src)->toBe("script-src 'self' 'unsafe-inline' *.cdn.example");
expect($src)->not->toContain('nonce-');
expect($src)->not->toContain('strict-dynamic');
});
test('nonce enforcement builds a strict enforced policy', function () {
$nonce = 'abcdefghijklmnopqrstuvwx';
$src = CactiSecureHeaders::buildEnforcedScriptSrc('nonce-enforce', '', $nonce);
expect($src)->toContain("'nonce-$nonce'");
expect($src)->toContain("'strict-dynamic'");
});
// --- None mode: legacy unsafe-eval value, most permissive ---
test('none mode adds unsafe-eval alongside unsafe-inline', function () {
$src = CactiSecureHeaders::buildScriptSrc('unsafe-eval', 'IGNORED', '');
expect($src)->toContain("'unsafe-eval'");
expect($src)->toContain("'unsafe-inline'");
expect($src)->not->toContain('nonce-');
});
// --- Nonce mode ---
test('nonce mode emits the nonce with strict-dynamic', function () {
$src = CactiSecureHeaders::buildScriptSrc('nonce', 'abcdefghijklmnopqrstuvwx', '');
expect($src)->toContain("'nonce-abcdefghijklmnopqrstuvwx'");
expect($src)->toContain("'strict-dynamic'");
// strict-dynamic needs unsafe-eval for jQuery globalEval; unsafe-inline is
// the pre-CSP3 fallback that supporting browsers ignore once a nonce is set.
expect($src)->toContain("'unsafe-eval'");
expect($src)->toContain("'unsafe-inline'");
});
test('nonce builder rejects malformed authorization tokens', function (string $nonce) {
CactiSecureHeaders::buildScriptSrc('nonce', $nonce, '');
})->with([
'empty' => '',
'too short' => 'abc123',
'quoted' => 'abcdefghijklmnopqrstu"wx',
'directive' => 'abcdefghijklmnop;script',
])->throws(InvalidArgumentException::class);
test('report-only script source is emitted only for nonce migration', function () {
$nonce = 'abcdefghijklmnopqrstuvwx';
expect(CactiSecureHeaders::buildReportOnlyScriptSrc('nonce', $nonce, ''))
->toContain("'nonce-$nonce'")
->toContain("'strict-dynamic'");
expect(CactiSecureHeaders::buildReportOnlyScriptSrc('', '', ''))->toBe('');
expect(CactiSecureHeaders::buildReportOnlyScriptSrc('unsafe-eval', '', ''))->toBe('');
});
test('nonce attributes are emitted only with a valid nonce', function () {
$nonce = 'abcdefghijklmnopqrstuvwx';
expect(CactiSecureHeaders::buildNonceAttribute('nonce', $nonce))
->toBe('nonce="' . $nonce . '"');
expect(CactiSecureHeaders::buildNonceAttribute('nonce-enforce', $nonce))
->toBe('nonce="' . $nonce . '"');
expect(CactiSecureHeaders::buildNonceAttribute('', ''))->toBe('');
expect(CactiSecureHeaders::buildNonceAttribute('unsafe-eval', ''))->toBe('');
});
test('nonce attributes reject malformed authorization tokens', function () {
CactiSecureHeaders::buildNonceAttribute('nonce', 'not-a-valid-nonce');
})->throws(InvalidArgumentException::class);
// --- nonce generation ---
test('nonce is base64url with no padding and stable within a request', function () {
$a = CactiSecureHeaders::getNonce();
$b = CactiSecureHeaders::getNonce();
expect($a)->toBe($b);
expect($a)->toMatch('/^[A-Za-z0-9_-]{20,}$/');
expect($a)->not->toContain('=');
});
test('nonce generation has no predictable fallback', function () {
$source = file_get_contents(dirname(__DIR__, 2) . '/lib/headers_secure.php');
expect($source)->not->toBeFalse()
->and($source)->not->toContain('uniqid(')
->and($source)->not->toContain('mt_rand(')
->and($source)->not->toContain('openssl_random_pseudo_bytes(')
->and($source)->toContain('throw new \\RuntimeException');
});
// --- report-uri suffix ---
test('report-uri suffix is attached only in nonce mode with a uri', function () {
expect(CactiSecureHeaders::buildReportUriDirective('nonce', '/cacti/csp_report.php'))
->toBe(' report-uri /cacti/csp_report.php;');
expect(CactiSecureHeaders::buildReportUriDirective('nonce', 'https://reports.example/csp'))
->toBe(' report-uri https://reports.example/csp;');
expect(CactiSecureHeaders::buildReportUriDirective('nonce-enforce', '/cacti/csp_report.php'))
->toBe(' report-uri /cacti/csp_report.php;');
expect(CactiSecureHeaders::buildReportUriDirective('', '/cacti/csp_report.php'))->toBe('');
expect(CactiSecureHeaders::buildReportUriDirective('unsafe-eval', '/cacti/csp_report.php'))->toBe('');
expect(CactiSecureHeaders::buildReportUriDirective('nonce', ''))->toBe('');
});
test('report-uri builder rejects malformed or unsafe endpoints', function (string $uri) {
CactiSecureHeaders::buildReportUriDirective('nonce', $uri);
})->with([
'protocol relative' => '//reports.example/csp',
'credentials' => 'https://user:pass@example.com/csp',
'query' => 'https://example.com/csp?token=secret',
'fragment' => 'https://example.com/csp#fragment',
'non-http scheme' => 'data:text/plain,csp',
'invalid hostname' => 'https://not_a_host.example/csp',
'directive break' => '/csp; script-src *',
])->throws(InvalidArgumentException::class);
test('report endpoint resolution accepts safe configuration or uses the bundled endpoint', function () {
expect(CactiSecureHeaders::resolveReportUri('/custom/csp', '/cacti'))
->toBe('/custom/csp');
expect(CactiSecureHeaders::resolveReportUri('https://reports.example/csp', '/cacti'))
->toBe('https://reports.example/csp');
expect(CactiSecureHeaders::resolveReportUri('javascript:alert(1)', '/cacti'))
->toBe('/cacti/csp_report.php');
expect(CactiSecureHeaders::resolveReportUri(false, ''))
->toBe('/csp_report.php');
expect(CactiSecureHeaders::resolveReportUri(false, "/cacti\r\nX-Test: injected"))
->toBe('/csp_report.php');
});
// --- nonce attribute reflects the active mode ---
test('nonce attribute is empty outside nonce mode', function () {
// read_config_option is either absent or the test stub returning false,
// so getCspMode() resolves to the default and no attribute is emitted.
expect(CactiSecureHeaders::getNonceAttribute())->toBe('');
expect(CactiSecureHeaders::isNonceMode())->toBeFalse();
});
test('runtime policy wrappers use compatible defaults without configuration', function () {
expect(CactiSecureHeaders::scriptSrc(''))
->toBe("script-src 'self' 'unsafe-inline' ");
expect(CactiSecureHeaders::reportOnlyScriptSrc(''))->toBe('');
expect(CactiSecureHeaders::reportUriDirective())->toBe('');
});
test('shared script emitters attach the request nonce', function () {
$root = dirname(__DIR__, 2);
foreach ([
'include/auth.php',
'include/global_session.php',
'lib/auth.php',
'lib/functions.php',
'lib/htmx.php',
] as $file) {
$source = file_get_contents($root . '/' . $file);
expect($source)->not->toBeFalse();
expect($source)->toContain('CactiSecureHeaders::getNonceAttribute()');
}
});
test('shared navigation logos use links instead of inline event handlers', function () {
$root = dirname(__DIR__, 2);
foreach (['include/top_header.php', 'include/top_general_header.php'] as $file) {
$source = file_get_contents($root . '/' . $file);
expect($source)->not->toBeFalse();
expect($source)->toContain("class='cactiLogo' href='");
expect($source)->not->toMatch('/\bon[a-z]+\s*=/i');
}
});
+14
View File
@@ -36,6 +36,7 @@ beforeEach(function () {
unset(
$config[OPTIONS_CLI]['htmx_enabled'],
$config[OPTIONS_CLI]['content_security_policy_script'],
$_SERVER['HTTP_HX_REQUEST']
);
});
@@ -161,6 +162,19 @@ test('htmx_script_tag carries src, integrity, and crossorigin when enabled', fun
->and($tag)->toContain("crossorigin='anonymous'");
});
test('htmx loader and CSRF wiring share the request nonce in migration mode', function () {
global $config;
$config[OPTIONS_CLI]['htmx_enabled'] = 'on';
$config[OPTIONS_CLI]['content_security_policy_script'] = 'nonce';
$tag = htmx_script_tag();
preg_match_all('/nonce="([A-Za-z0-9_-]{24})"/', $tag, $matches);
expect($matches[1])->toHaveCount(2);
expect(array_unique($matches[1]))->toHaveCount(1);
});
test('htmx_script_tag integrity matches the sha384 of the vendored file', function () {
global $config;
@@ -0,0 +1,299 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* End-to-end harness for the Inline JavaScript Protection setting. Each test
* boots the PHP built-in web server pointing at tests/integration/fixtures/
* with a given CSP_TEST_MODE and reads the response back, so header() side
* effects are observable and the wire format is proven for every mode:
* HTMX (default), Nonce Migration (report only), Nonce Enforcement, and
* None (unsafe-eval).
*
* Each server runs on its own free port and is torn down in a finally block
* so tests never share process state.
*/
function _csp_find_free_port(): int {
$sock = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
if ($sock === false) {
throw new RuntimeException("could not allocate free port: $errstr");
}
$name = stream_socket_get_name($sock, false);
fclose($sock);
$parts = explode(':', $name);
return (int) end($parts);
}
function _csp_start_server($mode, $alternates = '', $reportUri = '', $enforce = false) {
$port = _csp_find_free_port();
$docroot = realpath(__DIR__ . '/fixtures');
$php_bin = defined('PHP_BINARY') ? PHP_BINARY : 'php';
// Array form runs php -S directly, without a shell.
$cmd = [$php_bin, '-S', '127.0.0.1:' . (int) $port, '-t', $docroot, 'csp_fixture.php'];
// The built-in server logs every request to stderr. Send its stdout/stderr
// to the null device so an undrained pipe can never fill and deadlock the
// test (or leave the server blocked on a write).
$null = defined('PHP_OS_FAMILY') && PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null';
$descriptors = [
0 => ['pipe', 'r'],
1 => ['file', $null, 'a'],
2 => ['file', $null, 'a'],
];
$env = $_ENV;
$env['CSP_TEST_MODE'] = (string) $mode;
$env['CSP_TEST_ALTERNATES'] = (string) $alternates;
$env['CSP_TEST_REPORT_URI'] = (string) $reportUri;
$env['CSP_TEST_ENFORCE'] = $enforce ? '1' : '0';
$proc = proc_open($cmd, $descriptors, $pipes, $docroot, $env);
if (!is_resource($proc)) {
throw new RuntimeException('proc_open failed for php -S');
}
$deadline = microtime(true) + 3.0;
$ready = false;
// The first probes fail with "connection refused" until the server binds.
// That is expected, so swallow those warnings (the test runner would
// otherwise flag the test as risky) rather than relying on the '@' operator,
// which PHPUnit's error handler ignores.
set_error_handler(static function () {
return true;
});
try {
while (microtime(true) < $deadline) {
$probe = stream_socket_client('tcp://127.0.0.1:' . (int) $port, $errno, $errstr, 0.2);
if ($probe !== false) {
fclose($probe);
$ready = true;
break;
}
usleep(50000);
}
} finally {
restore_error_handler();
}
if (!$ready) {
proc_terminate($proc, 9);
proc_close($proc);
throw new RuntimeException('php -S failed to start on port ' . $port);
}
return ['proc' => $proc, 'port' => $port, 'pipes' => $pipes];
}
function _csp_stop_server($server): void {
if (!is_array($server) || empty($server['proc']) || !is_resource($server['proc'])) {
return;
}
proc_terminate($server['proc'], 15);
foreach ($server['pipes'] as $p) {
if (is_resource($p)) {
fclose($p);
}
}
proc_close($server['proc']);
}
/*
* GET the fixture in a single request. The built-in server spawns a fresh
* process per request, so a HEAD followed by a GET would produce two nonces;
* one request keeps the header nonce and the body nonce in sync.
*/
function _csp_fetch($port): array {
$ch = curl_init('http://127.0.0.1:' . (int) $port . '/');
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('curl failed: ' . $err);
}
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$header_size = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$body = substr($raw, $header_size);
$headers = [];
foreach (preg_split('/\r?\n/', trim(substr($raw, 0, $header_size))) as $line) {
if ($line === '' || strpos($line, 'HTTP/') === 0) {
continue;
}
$pos = strpos($line, ':');
if ($pos === false) {
continue;
}
$name = strtolower(trim(substr($line, 0, $pos)));
$headers[$name][] = trim(substr($line, $pos + 1));
}
return ['status' => $status, 'headers' => $headers, 'body' => (string) $body];
}
// ---- default (HTMX) ----
test('HTMX default keeps unsafe-inline and adds no nonce or eval', function () {
$server = _csp_start_server('');
try {
$resp = _csp_fetch($server['port']);
expect($resp['status'])->toBe(200);
$csp = $resp['headers']['content-security-policy'][0];
expect($csp)->toContain("script-src 'self' 'unsafe-inline'");
expect($csp)->not->toContain('nonce-');
expect($csp)->not->toContain("'unsafe-eval'");
expect($csp)->not->toContain('report-uri');
expect($resp['headers'])->not->toHaveKey('content-security-policy-report-only');
// bootstrap script carries no nonce attribute outside nonce mode
expect($resp['body'])->toContain("<script type='text/javascript' >");
} finally {
_csp_stop_server($server);
}
});
// ---- Nonce ----
test('Nonce migration emits a report-only nonce that matches the served script tag', function () {
$server = _csp_start_server('nonce');
try {
$resp = _csp_fetch($server['port']);
$csp = $resp['headers']['content-security-policy'][0];
$ro = $resp['headers']['content-security-policy-report-only'][0];
expect($csp)->toContain("script-src 'self' 'unsafe-inline'");
expect($csp)->not->toContain('nonce-');
expect($csp)->not->toContain("'strict-dynamic'");
expect($ro)->toContain("'strict-dynamic'");
expect($ro)->toContain("'unsafe-eval'");
expect($ro)->toContain('report-uri');
// The report-only header nonce and body nonce must be identical. The
// enforced compatibility policy leaves legacy scripts and handlers usable.
expect($ro)->toMatch('/\'nonce-([A-Za-z0-9_-]+)\'/');
preg_match('/\'nonce-([A-Za-z0-9_-]+)\'/', $ro, $hm);
preg_match('/<script[^>]*nonce="([A-Za-z0-9_-]+)"/', $resp['body'], $bm);
expect($bm[1] ?? 'no-body-nonce')->toBe($hm[1]);
expect($resp['body'])->toContain('window.legacyInlineRan = true;');
expect($resp['body'])->toContain("onclick='window.legacyHandlerRan = true;'");
} finally {
_csp_stop_server($server);
}
});
test('Nonce migration is report-only until legacy script sites are converted', function () {
$server = _csp_start_server('nonce');
try {
$resp = _csp_fetch($server['port']);
expect($resp['headers'])->toHaveKey('content-security-policy');
expect($resp['headers'])->toHaveKey('content-security-policy-report-only');
} finally {
_csp_stop_server($server);
}
});
test('Nonce enforcement selection remains report-only without the config gate', function () {
$server = _csp_start_server('nonce-enforce');
try {
$resp = _csp_fetch($server['port']);
expect($resp['headers']['content-security-policy'][0])
->toContain("script-src 'self' 'unsafe-inline'")
->not->toContain('nonce-');
expect($resp['headers'])->toHaveKey('content-security-policy-report-only');
} finally {
_csp_stop_server($server);
}
});
test('Nonce enforcement requires the config gate and uses the response nonce', function () {
$server = _csp_start_server('nonce-enforce', '', '', true);
try {
$resp = _csp_fetch($server['port']);
$csp = $resp['headers']['content-security-policy'][0];
expect($csp)->toContain("'strict-dynamic'");
expect($csp)->toContain('report-uri');
expect($resp['headers'])->not->toHaveKey('content-security-policy-report-only');
preg_match('/\'nonce-([A-Za-z0-9_-]+)\'/', $csp, $hm);
preg_match('/<script[^>]*nonce="([A-Za-z0-9_-]+)"/', $resp['body'], $bm);
expect($bm[1] ?? 'no-body-nonce')->toBe($hm[1] ?? 'no-header-nonce');
} finally {
_csp_stop_server($server);
}
});
// ---- None (legacy unsafe-eval) ----
test('None mode adds unsafe-eval alongside unsafe-inline and no nonce', function () {
$server = _csp_start_server('unsafe-eval');
try {
$resp = _csp_fetch($server['port']);
$csp = $resp['headers']['content-security-policy'][0];
expect($csp)->toContain("script-src 'self' 'unsafe-eval' 'unsafe-inline'");
expect($csp)->not->toContain('nonce-');
expect($csp)->not->toContain('report-uri');
expect($resp['headers'])->not->toHaveKey('content-security-policy-report-only');
} finally {
_csp_stop_server($server);
}
});
// ---- alternates flow into every directive ----
test('alternate sources appear in frame-ancestors and script-src', function () {
$server = _csp_start_server('', '*.cdn.example');
try {
$resp = _csp_fetch($server['port']);
$csp = $resp['headers']['content-security-policy'][0];
expect($csp)->toContain("frame-ancestors 'self' *.cdn.example");
expect($csp)->toContain('*.cdn.example');
} finally {
_csp_stop_server($server);
}
});
test('unsafe alternate sources cannot inject a CSP directive', function () {
$server = _csp_start_server('', 'https://cdn.example; script-src *');
try {
$resp = _csp_fetch($server['port']);
$csp = $resp['headers']['content-security-policy'][0];
expect($csp)->not->toContain('cdn.example');
expect($csp)->not->toContain('; script-src *');
expect($csp)->toContain("script-src 'self' 'unsafe-inline'");
} finally {
_csp_stop_server($server);
}
});
@@ -0,0 +1,75 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* Stand-alone fixture served by the built-in PHP web server
* (`php -S 127.0.0.1:<port> -t tests/integration/fixtures csp_fixture.php`).
*
* The active CSP mode comes from the CSP_TEST_MODE environment variable so
* the harness can drive every branch of the setting without a database. The
* header and <meta> lines below mirror include/global.php and lib/html.php,
* so the test proves the real wire format for each mode over HTTP.
*
* Dependency-free on purpose: only lib/headers_secure.php is loaded so a
* failure points at the header helper, not the Cacti bootstrap.
*/
require_once __DIR__ . '/../../../lib/headers_secure.php';
if (!defined('CACTI_CSP_NONCE_ENFORCE')) {
define('CACTI_CSP_NONCE_ENFORCE', getenv('CSP_TEST_ENFORCE') === '1');
}
if (!function_exists('read_config_option')) {
function read_config_option($key) {
switch ($key) {
case 'content_security_policy_script':
$v = getenv('CSP_TEST_MODE');
return $v === false ? '' : $v;
case 'content_security_alternate_sources':
$v = getenv('CSP_TEST_ALTERNATES');
return $v === false ? '' : $v;
case 'content_security_report_uri':
$v = getenv('CSP_TEST_REPORT_URI');
return $v === false ? '' : $v;
}
return '';
}
}
$alternates = CactiSecureHeaders::normalizeAlternateSources(read_config_option('content_security_alternate_sources'));
$script_src = CactiSecureHeaders::scriptSrc($alternates);
$report_uri = CactiSecureHeaders::getCspMode() === 'nonce-enforce' ?
CactiSecureHeaders::reportUriDirective() : '';
// Mirrors the HTTP header emitted by include/global.php.
header("Content-Security-Policy: default-src *; img-src 'self' https://api.qrserver.com $alternates data: blob:; style-src 'self' 'unsafe-inline' $alternates; $script_src; frame-ancestors 'self' $alternates; worker-src 'self' $alternates;$report_uri");
$report_script_src = CactiSecureHeaders::reportOnlyScriptSrc($alternates);
if ($report_script_src !== '') {
$report_uri = CactiSecureHeaders::reportUriDirective();
header("Content-Security-Policy-Report-Only: default-src *; img-src 'self' https://api.qrserver.com $alternates data: blob:; style-src 'self' 'unsafe-inline' $alternates; $report_script_src; frame-ancestors 'self' $alternates; worker-src 'self' $alternates;$report_uri");
}
$nonce_attr = CactiSecureHeaders::getNonceAttribute();
print "<!doctype html>\n<html><head>\n";
/* Mirrors the <meta> policy in lib/html.php: no frame-ancestors/report-uri,
* both of which are invalid inside a meta CSP. */
print "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src *; img-src 'self' https://api.qrserver.com $alternates data: blob:; style-src 'self' 'unsafe-inline' $alternates; $script_src; worker-src 'self' $alternates;\">\n";
print "</head><body>\n";
print "<script type='text/javascript' $nonce_attr>var x = 1;</script>\n";
print "<script type='text/javascript'>window.legacyInlineRan = true;</script>\n";
print "<button type='button' onclick='window.legacyHandlerRan = true;'>Legacy handler</button>\n";
print "</body></html>\n";
@@ -122,7 +122,7 @@ deserialize ./lib/reports.php:49 $items = unserialize($unstripped, ['allowed_cl
dynamic_include ./cli/audit_database.php:232 require_once($plugin . '/setup.php');
dynamic_include ./cli/audit_database.php:235 require_once($plugin . '/includes/database.php');
dynamic_include ./cli/upgrade_database.php:152 include($upgrade_file);
dynamic_include ./include/global.php:719 require_once($vendor_autoload);
dynamic_include ./include/global.php:715 require_once($vendor_autoload);
dynamic_include ./include/global_languages.php:178 require_once($providerFull);
dynamic_include ./index.php:49 require_once($file);
dynamic_include ./lib/installer.php:4110 include_once($upgrade_file);
@@ -172,6 +172,7 @@ fs_write ./lib/api_device.php:2892 if (file_put_contents($new_xmlfile, $da
fs_write ./lib/boost.php:611 if ($fileptr = fopen($cache_file, 'rb')) {
fs_write ./lib/boost.php:761 if ($fileptr = fopen($cache_file, 'w')) {
fs_write ./lib/clog_webapi.php:182 $log_fh = fopen($logfile, 'w');
fs_write ./lib/csp_report_endpoint.php:166 $inputStream = fopen('php://input', 'rb');
fs_write ./lib/database.php:241 file_put_contents(sys_get_temp_dir() . '/cacti-option.log',
fs_write ./lib/database.php:2538 file_put_contents(sys_get_temp_dir() . '/cacti-sql.log', get_debug_prefix() . $line, FILE_APPEND);
fs_write ./lib/database.php:79 file_put_contents(sys_get_temp_dir() . '/cacti-option.log',
@@ -469,7 +470,7 @@ header_redirect ./include/fa/svgs/index.php:25 header('Location:../index.php');
header_redirect ./include/fa/svgs/regular/index.php:25 header('Location:../index.php');
header_redirect ./include/fa/svgs/solid/index.php:25 header('Location:../index.php');
header_redirect ./include/fa/webfonts/index.php:25 header('Location:../index.php');
header_redirect ./include/global.php:583 header('Location: https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
header_redirect ./include/global.php:584 header('Location: https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
header_redirect ./include/index.php:25 header('Location:../index.php');
header_redirect ./include/js/LC_MESSAGES/index.php:25 header('Location:../index.php');
header_redirect ./include/js/index.php:25 header('Location:../index.php');
Can't render this file because it contains an unexpected character in line 22 and column 255.
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/../.." && pwd)"
base_image="${CACTI_DOCKER_BASE_IMAGE:-cacti-web}"
test_image="${CACTI_DOCKER_TEST_IMAGE:-cacti-test}"
docker build \
--tag "${base_image}" \
--file "${repo_root}/docker/Dockerfile" \
"${repo_root}/docker"
docker build \
--tag "${test_image}" \
--file "${repo_root}/docker/Dockerfile.test" \
"${repo_root}"
docker run --rm \
--env XDEBUG_MODE="${XDEBUG_MODE:-off}" \
"${test_image}" \
include/vendor/bin/pest --display-warnings "$@"