src/EventSubscriber/RequestOriginSubscriber.php line 24

Open in your IDE?
  1. <?php
  2. namespace App\EventSubscriber;
  3. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  4. use Symfony\Component\HttpFoundation\Request;
  5. use Symfony\Component\HttpFoundation\Response;
  6. use Symfony\Component\HttpKernel\Event\RequestEvent;
  7. use Symfony\Component\HttpKernel\KernelEvents;
  8. /**
  9.  * SÉCURITÉ (protection CSRF sans modifier les formulaires) : les actions qui modifient des données
  10.  * (POST/PUT/DELETE, et toute adresse contenant "delete", "supprim", "suppr", "remove") doivent venir
  11.  * d'une page du backoffice lui-même. On compare l'en-tête Origin (ou à défaut Referer) à l'hôte du site.
  12.  * Un lien piégé sur un autre site ne peut donc plus déclencher une suppression.
  13.  */
  14. class RequestOriginSubscriber implements EventSubscriberInterface
  15. {
  16.     public static function getSubscribedEvents(): array
  17.     {
  18.         return [KernelEvents::REQUEST => ['onKernelRequest', 6]];
  19.     }
  20.     public function onKernelRequest(RequestEvent $event): void
  21.     {
  22.         if (!$event->isMainRequest()) {
  23.             return;
  24.         }
  25.         $request = $event->getRequest();
  26.         $route = (string) $request->attributes->get('_route', '');
  27.         if ($route === '' || $route[0] === '_') {
  28.             return;
  29.         }
  30.         $safeMethod = \in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'], true);
  31.         $sensitivePath = (bool) preg_match('~(delete|supprim|suppr|remove)~i', $request->getPathInfo());
  32.         if ($safeMethod && !$sensitivePath) {
  33.             return;
  34.         }
  35.         $source = $request->headers->get('Origin');
  36.         if ($source === null || $source === '') {
  37.             $source = $request->headers->get('Referer');
  38.         }
  39.         if ($source === null || $source === '') {
  40.             // Aucune indication d'origine : on tolère un POST (cookie SameSite=Lax), pas une suppression par simple lien.
  41.             if ($safeMethod) {
  42.                 $event->setResponse($this->refus());
  43.             }
  44.             return;
  45.         }
  46.         if (!$this->memeHote($source, $request)) {
  47.             $event->setResponse($this->refus());
  48.         }
  49.     }
  50.     private function memeHote(string $source, Request $request): bool
  51.     {
  52.         $p = parse_url($source);
  53.         if (!\is_array($p) || empty($p['host'])) {
  54.             return false;
  55.         }
  56.         $hote = strtolower($p['host']);
  57.         $port = $p['port'] ?? (($p['scheme'] ?? '') === 'https' ? 443 : 80);
  58.         $attendu = strtolower($request->getHost());
  59.         $portAttendu = $request->getPort();
  60.         return $hote === $attendu && (int) $port === (int) $portAttendu;
  61.     }
  62.     private function refus(): Response
  63.     {
  64.         return new Response('Requête refusée (origine invalide).', 403, ['Content-Type' => 'text/plain; charset=UTF-8']);
  65.     }
  66. }