CspHeaderTest.php 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\App;
  4. use App\Tests\Integration\Support\AppTestCase;
  5. /**
  6. * Regression coverage for SEC_REVIEW F24: every rendered response must
  7. * carry a strict Content-Security-Policy with a per-request nonce, and
  8. * the only inline `<script>` block in the layout (the FOUC handler)
  9. * must be stamped with that same nonce so it can execute under the
  10. * stricter policy. `'unsafe-inline'` and `'unsafe-eval'` must not appear
  11. * anywhere in `script-src`.
  12. */
  13. final class CspHeaderTest extends AppTestCase
  14. {
  15. protected function setUp(): void
  16. {
  17. $this->bootApp();
  18. }
  19. public function testLoginPageCarriesStrictCsp(): void
  20. {
  21. $response = $this->request('GET', '/login');
  22. self::assertSame(200, $response->getStatusCode());
  23. $csp = $response->getHeaderLine('Content-Security-Policy');
  24. self::assertNotSame('', $csp, 'CSP header must be set');
  25. self::assertStringNotContainsString("'unsafe-inline'", self::scriptSrc($csp));
  26. self::assertStringNotContainsString("'unsafe-eval'", self::scriptSrc($csp));
  27. self::assertMatchesRegularExpression(
  28. "/script-src 'self' 'nonce-[A-Za-z0-9_-]+'/",
  29. $csp,
  30. );
  31. }
  32. public function testInlineScriptCarriesMatchingNonce(): void
  33. {
  34. $response = $this->request('GET', '/login');
  35. $body = (string) $response->getBody();
  36. self::assertMatchesRegularExpression(
  37. '/<script nonce="([A-Za-z0-9_-]+)">/',
  38. $body,
  39. 'layout FOUC <script> must be nonced',
  40. );
  41. preg_match('/<script nonce="([A-Za-z0-9_-]+)">/', $body, $m);
  42. $bodyNonce = $m[1];
  43. self::assertStringContainsString(
  44. "'nonce-{$bodyNonce}'",
  45. $response->getHeaderLine('Content-Security-Policy'),
  46. 'response CSP must contain the same nonce as the inline <script>',
  47. );
  48. }
  49. public function testNoncesDifferAcrossRequests(): void
  50. {
  51. $first = $this->request('GET', '/login');
  52. $second = $this->request('GET', '/login');
  53. $firstNonce = self::extractScriptNonce($first->getHeaderLine('Content-Security-Policy'));
  54. $secondNonce = self::extractScriptNonce($second->getHeaderLine('Content-Security-Policy'));
  55. self::assertNotSame('', $firstNonce);
  56. self::assertNotSame('', $secondNonce);
  57. self::assertNotSame($firstNonce, $secondNonce, 'nonce must rotate per response');
  58. }
  59. public function testHealthzAlsoCarriesCsp(): void
  60. {
  61. $response = $this->request('GET', '/healthz');
  62. self::assertSame(200, $response->getStatusCode());
  63. self::assertStringContainsString(
  64. "frame-ancestors 'none'",
  65. $response->getHeaderLine('Content-Security-Policy'),
  66. );
  67. }
  68. public function testNoBareInlineEventHandlersLeftInLoginTemplate(): void
  69. {
  70. $response = $this->request('GET', '/login');
  71. $body = (string) $response->getBody();
  72. // CSP build of Alpine cannot evaluate arbitrary inline expressions;
  73. // the migration replaced object-literal x-data with named components.
  74. self::assertStringNotContainsString('x-data="{', $body, 'no inline x-data object literals');
  75. // Inline DOM event handlers were never used, but assert it.
  76. self::assertDoesNotMatchRegularExpression(
  77. '/\son(click|submit|change|input)\s*=\s*"/i',
  78. $body,
  79. 'no inline DOM event handlers',
  80. );
  81. }
  82. private static function scriptSrc(string $csp): string
  83. {
  84. foreach (explode(';', $csp) as $part) {
  85. $part = trim($part);
  86. if (str_starts_with($part, 'script-src')) {
  87. return $part;
  88. }
  89. }
  90. return '';
  91. }
  92. private static function extractScriptNonce(string $csp): string
  93. {
  94. if (preg_match("/'nonce-([A-Za-z0-9_-]+)'/", self::scriptSrc($csp), $m)) {
  95. return $m[1];
  96. }
  97. return '';
  98. }
  99. }