CspMiddlewareTest.php 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  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. // Defence-in-depth: frame-ancestors / form-action / base-uri locked down.
  36. self::assertStringContainsString("frame-ancestors 'none'", $policy);
  37. self::assertStringContainsString("form-action 'self'", $policy);
  38. self::assertStringContainsString("base-uri 'self'", $policy);
  39. }
  40. private static function extractDirective(string $csp, string $name): string
  41. {
  42. foreach (explode(';', $csp) as $part) {
  43. $part = trim($part);
  44. if (str_starts_with($part, $name . ' ')) {
  45. return $part;
  46. }
  47. }
  48. return '';
  49. }
  50. public function testProcessSetsHeaderAndExposesNonceOnRequestAttribute(): void
  51. {
  52. $middleware = new CspMiddleware();
  53. $request = (new ServerRequestFactory())->createServerRequest('GET', '/');
  54. $rf = new ResponseFactory();
  55. $captured = null;
  56. $handler = new class ($rf, $captured) implements RequestHandlerInterface {
  57. public function __construct(
  58. private readonly ResponseFactory $rf,
  59. public ?string $captured,
  60. ) {
  61. }
  62. public function handle(ServerRequestInterface $request): ResponseInterface
  63. {
  64. $this->captured = $request->getAttribute(CspMiddleware::ATTR_NONCE);
  65. return $this->rf->createResponse(200);
  66. }
  67. };
  68. $response = $middleware->process($request, $handler);
  69. self::assertNotNull($handler->captured);
  70. self::assertNotSame('', $handler->captured);
  71. self::assertStringContainsString(
  72. "'nonce-{$handler->captured}'",
  73. $response->getHeaderLine('Content-Security-Policy'),
  74. );
  75. }
  76. }