src/EventSubscriber/SecurityHeadersSubscriber.php line 24

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace MedBrief\MSR\EventSubscriber;
  4. use MedBrief\MSR\Service\Security\HttpHeaders\ContentSecurityPolicyBuilder;
  5. use MedBrief\MSR\Service\Security\HttpHeaders\CspNonceProvider;
  6. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  7. use Symfony\Component\HttpKernel\Event\ResponseEvent;
  8. use Symfony\Component\HttpKernel\KernelEvents;
  9. final class SecurityHeadersSubscriber implements EventSubscriberInterface
  10. {
  11. public function __construct(private readonly CspNonceProvider $nonces, private readonly ContentSecurityPolicyBuilder $policy)
  12. {
  13. }
  14. public static function getSubscribedEvents(): array
  15. {
  16. return [KernelEvents::RESPONSE => [['prepareProfilerNonce', -127], ['onResponse', -1100]]];
  17. }
  18. public function prepareProfilerNonce(ResponseEvent $event): void
  19. {
  20. $headers = $event->getResponse()->headers;
  21. if (!$event->isMainRequest() || !$headers->has('X-Debug-Token')
  22. || !str_starts_with(strtolower($headers->get('Content-Type', '')), 'text/html')
  23. || $headers->has('Content-Disposition')) {
  24. return;
  25. }
  26. // Symfony's toolbar runs at -128. Give it the application's request-owned
  27. // nonce before injection; client-supplied profiler nonces are never trusted.
  28. foreach (['X-SymfonyProfiler-Script-Nonce', 'X-SymfonyProfiler-Style-Nonce'] as $header) {
  29. $event->getRequest()->headers->remove($header);
  30. $headers->set($header, $this->nonces->getNonce());
  31. }
  32. }
  33. public function onResponse(ResponseEvent $event): void
  34. {
  35. if (!$event->isMainRequest()) {
  36. return;
  37. }
  38. $response = $event->getResponse();
  39. $headers = $response->headers;
  40. if (!in_array($headers->get('Referrer-Policy'), ['no-referrer', 'same-origin', 'strict-origin'], true)) {
  41. $headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
  42. }
  43. $headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), bluetooth=()');
  44. $existing = $headers->get('Content-Security-Policy', '');
  45. // Preserve the explicitly restrictive attachment policies. Other pre-existing
  46. // policies intersect with ours, so an accidental permissive one cannot bypass it.
  47. if (trim($existing) !== "default-src 'none'; object-src 'none'; script-src 'none';") {
  48. $isHtml = str_starts_with(strtolower($headers->get('Content-Type', '')), 'text/html');
  49. $headers->set('Content-Security-Policy', $isHtml
  50. ? $this->policy->build($this->nonces->getNonce())
  51. : "default-src 'none'; object-src 'none'; script-src 'none'; base-uri 'none'; frame-ancestors 'self';", false);
  52. if ($isHtml) {
  53. // A cached body must never be paired with a different response's nonce.
  54. $headers->set('Cache-Control', 'private, no-store');
  55. $headers->remove('ETag');
  56. $headers->remove('Last-Modified');
  57. $headers->remove('Expires');
  58. }
  59. }
  60. }
  61. }