mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
* 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>
243 lines
10 KiB
PHP
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');
|
|
}
|
|
});
|