<?php
declare(strict_types=1);
namespace MedBrief\MSR\EventSubscriber;
use MedBrief\MSR\Service\Security\HttpHeaders\ContentSecurityPolicyBuilder;
use MedBrief\MSR\Service\Security\HttpHeaders\CspNonceProvider;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class SecurityHeadersSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly CspNonceProvider $nonces, private readonly ContentSecurityPolicyBuilder $policy)
{
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => [['prepareProfilerNonce', -127], ['onResponse', -1100]]];
}
public function prepareProfilerNonce(ResponseEvent $event): void
{
$headers = $event->getResponse()->headers;
if (!$event->isMainRequest() || !$headers->has('X-Debug-Token')
|| !str_starts_with(strtolower($headers->get('Content-Type', '')), 'text/html')
|| $headers->has('Content-Disposition')) {
return;
}
// Symfony's toolbar runs at -128. Give it the application's request-owned
// nonce before injection; client-supplied profiler nonces are never trusted.
foreach (['X-SymfonyProfiler-Script-Nonce', 'X-SymfonyProfiler-Style-Nonce'] as $header) {
$event->getRequest()->headers->remove($header);
$headers->set($header, $this->nonces->getNonce());
}
}
public function onResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$response = $event->getResponse();
$headers = $response->headers;
if (!in_array($headers->get('Referrer-Policy'), ['no-referrer', 'same-origin', 'strict-origin'], true)) {
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
}
$headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), bluetooth=()');
$existing = $headers->get('Content-Security-Policy', '');
// Preserve the explicitly restrictive attachment policies. Other pre-existing
// policies intersect with ours, so an accidental permissive one cannot bypass it.
if (trim($existing) !== "default-src 'none'; object-src 'none'; script-src 'none';") {
$isHtml = str_starts_with(strtolower($headers->get('Content-Type', '')), 'text/html');
$headers->set('Content-Security-Policy', $isHtml
? $this->policy->build($this->nonces->getNonce())
: "default-src 'none'; object-src 'none'; script-src 'none'; base-uri 'none'; frame-ancestors 'self';", false);
if ($isHtml) {
// A cached body must never be paired with a different response's nonce.
$headers->set('Cache-Control', 'private, no-store');
$headers->remove('ETag');
$headers->remove('Last-Modified');
$headers->remove('Expires');
}
}
}
}