Files
cacti/tests/integration/ContentSecurityPolicyTest.php
Thomas Vincent d4e255b5d9 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>
2026-08-16 21:38:46 -04:00

300 lines
9.6 KiB
PHP

<?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);
}
});