mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
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:
1 parent
ed09adfffd
commit
d4e255b5d9
24 files changed
+1514
-47
No files matched your search
@@ -3,8 +3,6 @@
|
||||
*.md
|
||||
!README.md
|
||||
LICENSE
|
||||
.phpstan*
|
||||
.php-cs-fixer.php
|
||||
.pre-commit-config.yaml
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -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');
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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"');
|
||||
|
||||
@@ -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]+)$/']]); ?>';
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
@@ -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>';
|
||||
|
||||
@@ -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
@@ -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' : '');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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.
|
Executable
+22
@@ -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 "$@"
|
||||
Reference in new issue
Block a user