1
0

CspMiddlewareTest.php 3.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Http;
  4. use App\Http\CspMiddleware;
  5. use PHPUnit\Framework\TestCase;
  6. use Psr\Http\Message\ResponseInterface;
  7. use Psr\Http\Message\ServerRequestInterface;
  8. use Psr\Http\Server\RequestHandlerInterface;
  9. use Slim\Psr7\Factory\ResponseFactory;
  10. use Slim\Psr7\Factory\ServerRequestFactory;
  11. final class CspMiddlewareTest extends TestCase
  12. {
  13. public function testGeneratedNoncesAreUniqueAndUrlSafe(): void
  14. {
  15. $seen = [];
  16. for ($i = 0; $i < 50; $i++) {
  17. $nonce = CspMiddleware::generateNonce();
  18. self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
  19. self::assertFalse(in_array($nonce, $seen, true), 'nonce should be unique');
  20. $seen[] = $nonce;
  21. }
  22. }
  23. public function testPolicyContainsNonceAndDropsUnsafeDirectives(): void
  24. {
  25. $nonce = 'TESTNONCE123';
  26. $policy = CspMiddleware::policy($nonce);
  27. self::assertStringContainsString("'nonce-{$nonce}'", $policy);
  28. self::assertStringNotContainsString("'unsafe-eval'", $policy);
  29. // `script-src` directive itself must not list `'unsafe-inline'`.
  30. $scriptSrc = self::extractDirective($policy, 'script-src');
  31. self::assertNotSame('', $scriptSrc, 'script-src directive missing');
  32. self::assertStringNotContainsString("'unsafe-inline'", $scriptSrc);
  33. self::assertStringContainsString("'self'", $scriptSrc);
  34. self::assertStringContainsString("'nonce-{$nonce}'", $scriptSrc);
  35. // SEC_REVIEW F62: `style-src` must NOT contain `'unsafe-inline'`.
  36. // Inline styles attribute selectors would otherwise let an
  37. // attacker exfiltrate secrets like the CSRF token char-by-char.
  38. $styleSrc = self::extractDirective($policy, 'style-src');
  39. self::assertNotSame('', $styleSrc, 'style-src directive missing');
  40. self::assertStringNotContainsString("'unsafe-inline'", $styleSrc);
  41. self::assertSame("style-src 'self'", $styleSrc);
  42. // Defence-in-depth: frame-ancestors / form-action / base-uri locked down.
  43. self::assertStringContainsString("frame-ancestors 'none'", $policy);
  44. self::assertStringContainsString("form-action 'self'", $policy);
  45. self::assertStringContainsString("base-uri 'self'", $policy);
  46. }
  47. private static function extractDirective(string $csp, string $name): string
  48. {
  49. foreach (explode(';', $csp) as $part) {
  50. $part = trim($part);
  51. if (str_starts_with($part, $name . ' ')) {
  52. return $part;
  53. }
  54. }
  55. return '';
  56. }
  57. public function testProcessSetsHeaderAndExposesNonceOnRequestAttribute(): void
  58. {
  59. $middleware = new CspMiddleware();
  60. $request = (new ServerRequestFactory())->createServerRequest('GET', '/');
  61. $rf = new ResponseFactory();
  62. $captured = null;
  63. $handler = new class ($rf, $captured) implements RequestHandlerInterface {
  64. public function __construct(
  65. private readonly ResponseFactory $rf,
  66. public ?string $captured,
  67. ) {
  68. }
  69. public function handle(ServerRequestInterface $request): ResponseInterface
  70. {
  71. $this->captured = $request->getAttribute(CspMiddleware::ATTR_NONCE);
  72. return $this->rf->createResponse(200);
  73. }
  74. };
  75. $response = $middleware->process($request, $handler);
  76. self::assertNotNull($handler->captured);
  77. self::assertNotSame('', $handler->captured);
  78. self::assertStringContainsString(
  79. "'nonce-{$handler->captured}'",
  80. $response->getHeaderLine('Content-Security-Policy'),
  81. );
  82. }
  83. }