Files
cacti/tests/Unit/HeadersSecureTest.php
T
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

243 lines
10 KiB
PHP

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