| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\App;
- use App\Tests\Integration\Support\AppTestCase;
- /**
- * Regression coverage for SEC_REVIEW F24: every rendered response must
- * carry a strict Content-Security-Policy with a per-request nonce, and
- * the only inline `<script>` block in the layout (the FOUC handler)
- * must be stamped with that same nonce so it can execute under the
- * stricter policy. `'unsafe-inline'` and `'unsafe-eval'` must not appear
- * anywhere in `script-src`.
- */
- final class CspHeaderTest extends AppTestCase
- {
- protected function setUp(): void
- {
- $this->bootApp();
- }
- public function testLoginPageCarriesStrictCsp(): void
- {
- $response = $this->request('GET', '/login');
- self::assertSame(200, $response->getStatusCode());
- $csp = $response->getHeaderLine('Content-Security-Policy');
- self::assertNotSame('', $csp, 'CSP header must be set');
- self::assertStringNotContainsString("'unsafe-inline'", self::scriptSrc($csp));
- self::assertStringNotContainsString("'unsafe-eval'", self::scriptSrc($csp));
- self::assertMatchesRegularExpression(
- "/script-src 'self' 'nonce-[A-Za-z0-9_-]+'/",
- $csp,
- );
- }
- public function testInlineScriptCarriesMatchingNonce(): void
- {
- $response = $this->request('GET', '/login');
- $body = (string) $response->getBody();
- self::assertMatchesRegularExpression(
- '/<script nonce="([A-Za-z0-9_-]+)">/',
- $body,
- 'layout FOUC <script> must be nonced',
- );
- preg_match('/<script nonce="([A-Za-z0-9_-]+)">/', $body, $m);
- $bodyNonce = $m[1];
- self::assertStringContainsString(
- "'nonce-{$bodyNonce}'",
- $response->getHeaderLine('Content-Security-Policy'),
- 'response CSP must contain the same nonce as the inline <script>',
- );
- }
- public function testNoncesDifferAcrossRequests(): void
- {
- $first = $this->request('GET', '/login');
- $second = $this->request('GET', '/login');
- $firstNonce = self::extractScriptNonce($first->getHeaderLine('Content-Security-Policy'));
- $secondNonce = self::extractScriptNonce($second->getHeaderLine('Content-Security-Policy'));
- self::assertNotSame('', $firstNonce);
- self::assertNotSame('', $secondNonce);
- self::assertNotSame($firstNonce, $secondNonce, 'nonce must rotate per response');
- }
- public function testHealthzAlsoCarriesCsp(): void
- {
- $response = $this->request('GET', '/healthz');
- self::assertSame(200, $response->getStatusCode());
- self::assertStringContainsString(
- "frame-ancestors 'none'",
- $response->getHeaderLine('Content-Security-Policy'),
- );
- }
- public function testNoBareInlineEventHandlersLeftInLoginTemplate(): void
- {
- $response = $this->request('GET', '/login');
- $body = (string) $response->getBody();
- // CSP build of Alpine cannot evaluate arbitrary inline expressions;
- // the migration replaced object-literal x-data with named components.
- self::assertStringNotContainsString('x-data="{', $body, 'no inline x-data object literals');
- // Inline DOM event handlers were never used, but assert it.
- self::assertDoesNotMatchRegularExpression(
- '/\son(click|submit|change|input)\s*=\s*"/i',
- $body,
- 'no inline DOM event handlers',
- );
- }
- private static function scriptSrc(string $csp): string
- {
- foreach (explode(';', $csp) as $part) {
- $part = trim($part);
- if (str_starts_with($part, 'script-src')) {
- return $part;
- }
- }
- return '';
- }
- private static function extractScriptNonce(string $csp): string
- {
- if (preg_match("/'nonce-([A-Za-z0-9_-]+)'/", self::scriptSrc($csp), $m)) {
- return $m[1];
- }
- return '';
- }
- }
|