CSRF — tokens, SameSite, double-submit, stateless APIs
TL;DR — CSRF (Cross-Site Request Forgery) exploite les cookies envoyés automatiquement par le navigateur. La parade côté Symfony : token CSRF (par défaut un secret stocké en session) inclus dans chaque formulaire, vérifié à la soumission.
SameSite=Lax/Strictsur les cookies est une defense in depth. Sur API stateless (JWT en header Authorization), CSRF n'est pas nécessaire — mais reste obligatoire dès qu'on stocke des credentials dans un cookie.
Mental model — ASCII diagram + analogy
Analogie : Imagine un site bancaire avec cookie de session. Tu visites evil.com qui contient <img src="https://bank.com/transfer?to=hacker">. Ton navigateur joint automatiquement ton cookie bancaire → transfert exécuté. Le CSRF token est un mot de passe à usage unique présent dans le formulaire — evil.com ne peut pas le deviner.
GET /transfer (form page)
│
▼
Server génère csrf_token('transfer_intention')
│
▼
HTML returned:
<form>
<input name="_token" value="abc123..."/>
...
</form>
│
▼
POST /transfer with cookies + _token=abc123
│
▼
Server vérifie token via CsrfTokenManager
│
┌────┴────┐
▼ ▼
OK invalid → 403Le secret n'est pas dans le cookie de session par défaut. Il est dans la session côté serveur. L'attaquant ne peut pas lire la session.
Code minimal — realistic snippet
Formulaire Symfony (CSRF activé par défaut)
<?php
namespace App\Form;
use App\Entity\Transfer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class TransferType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('amount', MoneyType::class)
->add('beneficiary');
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Transfer::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'transfer_form', // intention name
]);
}
}Manual CSRF token dans Twig (pour AJAX / lien custom)
<form action="{{ path('post_delete', { id: post.id }) }}" method="post">
<input type="hidden" name="_token" value="{{ csrf_token('delete-post-' ~ post.id) }}">
<button>Delete</button>
</form><?php
namespace App\Controller;
use App\Entity\Post;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
final class PostDeleteController extends AbstractController
{
// Symfony 7.1+ : l'id du token peut être une Expression évaluée par requête.
// Ici on reconstruit 'delete-post-<id>' pour matcher le csrf_token() Twig per-item.
#[Route('/post/{id}/delete', methods: ['POST'])]
#[IsCsrfTokenValid(new Expression('"delete-post-" ~ args["post"].getId()'), tokenKey: '_token')]
public function __invoke(Post $post, Request $request): Response
{
// L'attribute valide AVANT d'entrer ici (lève AccessDeniedHttpException -> 403 si KO).
// Équivalent manuel, si vous n'utilisez pas l'attribute :
// if (!$this->isCsrfTokenValid('delete-post-' . $post->getId(), $request->getPayload()->getString('_token'))) {
// throw $this->createAccessDeniedException();
// }
return $this->redirectToRoute('post_list');
}
}Piège classique : un
csrf_token_idstatique dans l'attribute ('delete-post') ne matchera jamais un token Twig généré par item ('delete-post-' ~ post.id). L'id doit être identique des deux côtés — d'où l'Expressionci-dessus (Symfony 7.1+). Avant 7.1, utilisez la validation manuelleisCsrfTokenValid()ou un id statique des deux côtés.
Cookies SameSite (defense in depth)
# config/packages/framework.yaml
framework:
session:
cookie_secure: true
cookie_samesite: lax # 'lax' (def), 'strict', 'none' (require secure)
cookie_httponly: true
gc_maxlifetime: 7200Lax: cookies envoyés sur navigation top-level GET ; pas sur POST cross-site.Strict: cookies envoyés uniquement sur même site (peut casser SSO).None+Secure: explicite, indispensable pour cross-site iframes.
Stateless CSRF token storage (cookie-based, double-submit)
# config/packages/framework.yaml
framework:
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logoutAvec un stateless_token_id, Symfony stocke le token dans un cookie chiffré au lieu de la session. Utile pour API + Hotwire / Turbo.
Patterns courants — 3–6 patterns
- Token par intention :
csrf_token_id: 'delete-post'. Si on génère "global" partout, un attaquant qui exfiltre un token peut l'utiliser ailleurs. - Token par item : ajouter l'ID
'delete-post-' ~ post.idpour empêcher un token valide pour le post 1 d'être réutilisé sur le post 2. - SameSite=Lax + token CSRF : combinaison standard moderne. SameSite ne suffit pas seul (vieux navigateurs, sous-domaines).
- Double-submit cookie : pour API où la session n'existe pas, le client génère un token, l'envoie en cookie ET en header
X-CSRF-Token. Le serveur compare. Pas besoin d'état serveur. stateless: true+ JWT enAuthorizationheader : pas de cookie → pas de CSRF.- Rotation post-login : après authentification, invalider l'ancien token CSRF. Symfony gère ça via régénération de session.
Versions — Symfony 5.4 / 6.4 / 7.x
| Topic | 5.4 | 6.4 | 7.x |
|---|---|---|---|
csrf_protection activé par défaut | OK | OK | OK |
is_csrf_token_valid Twig fn | Présent | Présent | Présent |
IsCsrfTokenValid attribute | Absent | 6.2+ | OK |
| Stateless CSRF (cookie-based) | Absent | 6.4+ | OK |
csrf-token-manager service id | OK | OK | OK |
enable_csrf sur form_login | OK | OK | OK |
Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage | OK | OK | OK |
- En 7.x, les stateless token providers ont mûri (cookie chiffré HMAC, sans session).
- Les enums
CsrfToken::classetc. sont stables.
Quand CSRF est obligatoire vs inutile
| Scénario | CSRF utile ? |
|---|---|
| App Symfony classique avec session cookie | OUI |
| SPA same-origin + cookie HttpOnly de session | OUI |
API REST avec JWT en header Authorization | NON |
API qui s'authentifie via header X-API-Key | NON |
| Webhook POST cross-origin (Stripe, GitHub) | Non (signature HMAC à la place) |
| Login form | OUI (sinon "login CSRF" possible) |
| Logout endpoint | OUI (sinon "logout CSRF" trolling) |
| Site public lecture seule (GET only) | NON (mais soyez sûrs qu'aucun GET n'est mutatif) |
Pitfalls — 5–8 concrete traps
- GET mutatif :
GET /delete?id=42est CSRF-able même avec token (un<img>n'envoie pas le token). Jamais de mutation en GET. csrf_protection: falseglobal : désactiver par paresse → ouvre tout. Désactiver seulement sur endpoints stateless explicites.- Token global réutilisé :
csrf_token('a')accepté partout → mauvaise pratique. Token par intention. - SameSite=Strict casse SSO : un user authentifié sur
auth.example.comqui clique versapp.example.comne porte pas son cookie. Tester en multi-domaine. stateless_token_ids+ session active : conflit de stockage. Soit on est stateful, soit on est stateless, pas les deux sur le même token id.- AJAX sans token : oubli de passer le token dans
fetch()→ 403 mystérieux en POST. Utiliser un meta tag global :html<meta name="csrf-token" content="{{ csrf_token('global') }}"> - Régénération de session non triggered post-login : risque de session fixation (proche de CSRF). Symfony régénère par défaut, vérifier qu'on ne désactive pas.
cookie_secure: falseen prod : cookie envoyé en clair sur HTTP → vol facile. Toujourstrueen production.- Reverse proxy qui drop le cookie : Cloudflare / Varnish peut altérer SameSite — tester en bout de chaîne.
Testing — phpunit / functional
<?php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class CsrfTest extends WebTestCase
{
public function testPostWithoutTokenIsRejected(): void
{
$client = static::createClient();
$client->request('POST', '/post/1/delete');
self::assertResponseStatusCodeSame(403);
}
public function testPostWithValidTokenSucceeds(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/post/1');
$form = $crawler->selectButton('Delete')->form();
$client->submit($form);
self::assertResponseRedirects('/posts');
}
public function testApiBearerIsExemptFromCsrf(): void
{
$client = static::createClient();
$client->request('POST', '/api/posts', server: [
'HTTP_AUTHORIZATION' => 'Bearer ' . self::getJwt(),
'CONTENT_TYPE' => 'application/json',
], content: '{"title":"hello"}');
self::assertResponseIsSuccessful();
}
}Crawler::selectButton('Delete')->form() récupère automatiquement le token caché du form.
🎬 Cas d'usage concrets
Scénario 1 — Formulaires sensibles dans un DMS de cabinet juridique
Le DMS du cabinet expose une centaine de formulaires HTML couvrant la création de dossier, l'upload de pièces confidentielles, le scellement d'un dossier (action irréversible) et la suppression définitive d'un document après délai légal. Tous ces formulaires utilisent Form Symfony qui injecte automatiquement un token CSRF _token par formulaire. Le scellement, particulièrement sensible, expose un formulaire ad hoc avec csrf_token_id dédié dossier_seal_{id} lié au dossier ciblé, ce qui empêche le rejeu d'un token volé sur un autre dossier. Les sessions sont configurées en cookie_samesite: strict et cookie_secure: true, durée 8 heures. Les uploads transitent par un endpoint POST classique avec token CSRF dans le multipart, jamais par un endpoint REST autonome. L'équipe valide la couverture CSRF via un test fonctionnel global qui scanne toutes les routes POST/PUT/DELETE et échoue si une route n'exige pas de token sans annotation #[NoCsrf] justifiée.
Scénario 2 — Checkout e-commerce avec gestion de double-submit
La marketplace e-commerce de mode protège son tunnel de checkout en trois étapes (adresse, paiement, validation) avec des tokens CSRF distincts par étape. La validation finale qui déclenche le paiement Stripe utilise un csrf_token_id checkout_pay_{cart_id} pour empêcher qu'un attaquant déclenche un paiement depuis un onglet zombie. Pour les utilisateurs revenant via le bouton retour navigateur, l'app détecte un token expiré (rotation à chaque commande validée) et propose un rechargement du formulaire avec un nouveau token plutôt que d'afficher une erreur. La SPA "shop minimaliste" en Stimulus utilise le pattern meta name="csrf-token" injecté côté serveur et un intercepteur axios ajoutant l'en-tête X-CSRF-Token. Les webhooks Stripe entrants sont protégés différemment (signature HMAC, pas CSRF) sur un endpoint #[NoCsrf] documenté.
Scénario 3 — Backoffice admin d'un SaaS comptable français
Le backoffice du SaaS comptable centralise des actions à très fort impact : invalidation d'un exercice clôturé, suppression d'un dossier client, export massif d'archives, changement d'IBAN bancaire de la société. Chaque formulaire admin utilise un token CSRF avec un csrf_token_id dérivé de l'identité de l'opérateur et de l'action (admin_iban_change_{tenant}). En complément, certaines actions ultra-sensibles imposent une réauthentification fraîche (step-up auth) via mot de passe ou 2FA dans les 5 minutes précédentes ; la validation du formulaire échoue si la fraîcheur n'est pas remplie. Les sessions admin sont cloisonnées sur un firewall séparé avec cookie distinct (admin_session) et timeout d'inactivité de 15 minutes. La revue trimestrielle de conformité valide que tous les écrans admin ont un test fonctionnel forçant un token expiré et vérifiant le rejet (403 ou 419).
🛠️ Exemple end-to-end
Use case : formulaire de scellement de dossier juridique avec token CSRF nommé par dossier et test fonctionnel de rejet sur token invalide.
<?php
// src/UI/Http/Form/SceauDossierType.php
declare(strict_types=1);
namespace App\UI\Http\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SceauDossierType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('motif', TextType::class, [
'label' => 'Motif du scellement',
'constraints' => [new NotBlank()],
])
->add('confirmer', SubmitType::class, ['label' => 'Sceller']);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired('dossier_id');
$resolver->setDefaults([
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => fn (array $options): string => 'dossier_seal_' . $options['dossier_id'],
]);
}
}
// src/UI/Http/Controller/SceauController.php
namespace App\UI\Http\Controller;
use App\Application\Dossier\Command\SceauDossier;
use App\Domain\Dossier\Entity\Dossier;
use App\Security\Voter\DossierVoter;
use App\UI\Http\Form\SceauDossierType;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class SceauController extends AbstractController
{
public function __construct(private readonly MessageBusInterface $bus) {}
#[Route('/dossiers/{id}/sceller', methods: ['GET', 'POST'])]
#[IsGranted(DossierVoter::ARCHIVE, subject: 'dossier')]
public function sceller(#[MapEntity] Dossier $dossier, Request $request): Response
{
$form = $this->createForm(SceauDossierType::class, null, [
'dossier_id' => (string) $dossier->getId(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->bus->dispatch(new SceauDossier($dossier->getId(), $form->getData()['motif']));
return $this->redirectToRoute('dossier_show', ['id' => $dossier->getId()]);
}
return $this->render('dossier/sceller.html.twig', [
'form' => $form->createView(),
'dossier' => $dossier,
]);
}
}
// tests/Functional/SceauCsrfTest.php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class SceauCsrfTest extends WebTestCase
{
public function testFormulaireSansTokenEstRejete(): void
{
$client = static::createClient();
$client->loginUser($this->associeAvecDossier());
$client->request('POST', '/dossiers/' . $this->dossierId . '/sceller', [
'sceau_dossier' => ['motif' => 'audit'], // _token absent
]);
self::assertResponseStatusCodeSame(422);
}
}Quand utiliser / éviter
- Toujours : sur formulaires HTML traditionnels avec session cookie.
- Toujours : sur login/logout, même si l'endpoint vous semble "public".
- Pas nécessaire : sur API stateless authentifiées par header.
- À reconsidérer : si SameSite=Strict est implémenté ET tous les browsers cibles le supportent ET pas de fallback nécessaire — mais le coût d'ajouter un token est trivial.
Double-submit cookie pattern — détaillé
Pour API stateless qui s'authentifie par cookie HttpOnly (et non JWT en header), CSRF reste pertinent. Le double-submit fonctionne ainsi :
1. GET /api/csrf-token
Server: Set-Cookie: csrf=abc123; SameSite=Lax; Secure
Response body: { "token": "abc123" }
2. POST /api/transfer
Client envoie:
Cookie: csrf=abc123 (auto)
X-CSRF-Token: abc123 (manuel, JS lit son propre cookie)
3. Serveur compare cookie csrf == header X-CSRF-Token
Si égal -> OK ; sinon -> 403Pourquoi c'est sûr : evil.com ne peut pas lire le cookie csrf (Same-Origin Policy), donc ne peut pas forger le header X-CSRF-Token.
<?php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
#[AsEventListener(event: 'kernel.request', priority: 50)]
final class DoubleSubmitCsrfListener
{
public function __invoke(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return; // ne pas re-valider sur les sous-requêtes (forward, ESI)
}
$req = $event->getRequest();
if (!in_array($req->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
return;
}
if (!str_starts_with($req->getPathInfo(), '/api')) {
return;
}
$cookieToken = $req->cookies->get('csrf');
$headerToken = $req->headers->get('X-CSRF-Token');
// hash_equals AVANT toute autre logique pour rester constant-time.
// L'ordre des checks est important : on ne court-circuite pas sur la longueur.
if (!is_string($cookieToken) || !is_string($headerToken)
|| !hash_equals($cookieToken, $headerToken)) {
throw new AccessDeniedHttpException('CSRF token mismatch'); // 403, pas 400
}
}
}Notes de production :
hash_equals()est obligatoire :===fuit la position du premier octet divergent par son temps d'exécution (timing attack).hash_equalscompare en temps constant.- Le token cookie doit être non prévisible :
bin2hex(random_bytes(32)), jamaisuniqid()/mt_rand()(non cryptographiques). - Sous-domaines : si
evil.attacker.example.compeut écrire un cookie sur.example.com, le double-submit naïf est contournable (cookie injection). Parade : préfixer le cookie avec__Host-(__Host-csrf), qui interdit l'attributDomainet forceSecure+Path=/. C'est la version durcie ("signed double-submit" / OWASP). - Le
priority: 50place ce listener après le routing (priorité 32) seulement si vous avez besoin des attributs de route ; ici on lit le path brut, donc une priorité haute (avant le firewall) est acceptable.
Tableau récapitulatif : quelle protection pour quel scénario
| App | Auth | CSRF strategy |
|---|---|---|
| Monolithe Symfony Twig | Session cookie | Token CSRF Symfony (form auto + manual) |
| SPA same-origin | Session cookie | Stateless CSRF cookie ou double-submit |
| SPA cross-origin | JWT in Authorization header | Aucune (mais CORS strict) |
| Mobile app | JWT in header | Aucune |
| Webhook receiver | HMAC signature | Aucune (HMAC remplace CSRF) |
Comment un staff engineer raisonne sur le CSRF
Le réflexe junior est « j'ajoute un token, c'est protégé ». Le réflexe staff est de modéliser la menace avant de choisir le mécanisme. La question fondatrice : est-ce que le navigateur attache automatiquement une credential que le serveur considère comme une preuve d'identité ? Si oui (cookie de session, Basic auth mémorisé, certificat client) → vous êtes CSRF-able et il faut un secret non-attaché-automatiquement (un token dans le body/header). Si non (JWT lu et posé manuellement en header Authorization par votre JS) → l'attaquant cross-site ne peut pas le reproduire, CSRF est hors-scope.
À partir de là, tout découle d'un seul invariant :
L'attaquant peut déclencher des requêtes vers votre origine, mais ne peut ni lire vos réponses ni lire/forger un secret lié à votre origine (Same-Origin Policy). La protection CSRF consiste à exiger, dans chaque mutation, une preuve que l'attaquant ne peut pas fabriquer.
Les trois familles de solutions ne sont que des manières différentes de matérialiser ce secret :
| Stratégie | Où vit le secret | État serveur ? | Quand le choisir |
|---|---|---|---|
| Synchronizer token (Symfony par défaut) | Session serveur | Oui (session) | Monolithe avec session. Le plus robuste. |
| Stateless / signed token (cookie HMAC) | Cookie chiffré/signé | Non | App sans session, Hotwire/Turbo, multi-instances sans session partagée. |
| Double-submit | Cookie + header (le JS recopie) | Non | API authentifiée par cookie où vous ne pouvez pas tenir d'état. Plus faible (cookie injection). |
Threat model rapide (le tableau que je dessine au whiteboard)
| Vecteur | Bloqué par token ? | Bloqué par SameSite ? | Note |
|---|---|---|---|
<form> auto-submit POST depuis evil.com | OUI | Lax/Strict OUI | Le cas d'école. |
<img> / <link> GET cross-site | Seulement si pas de mutation en GET | Lax: non (top-level GET), mais GET ne doit pas muter | Ne jamais muter en GET. |
fetch() cross-origin avec credentials | OUI | OUI + CORS bloque la lecture | Le préflight + CORS aident, mais ne remplacent pas le token. |
| Sous-domaine compromis écrivant un cookie | OUI (synchronizer) | Non | Double-submit naïf KO → __Host- prefix. |
| Login CSRF (forcer la victime à se logger dans le compte de l'attaquant) | OUI (token sur le form de login) | Partiel | Symfony : enable_csrf: true sur form_login. |
| BREACH-style leak du token via compression+reflection | — | — | Ne pas refléter le token dans une réponse compressée contrôlable. |
Pourquoi SameSite ne suffit pas (et n'est qu'une defense-in-depth)
- Couverture navigateur :
SameSite=Laxest le défaut moderne, mais des clients anciens / webviews exotiques l'ignorent. - Définition de « site » = eTLD+1, pas origin.
a.example.cometb.example.comsont same-site. Un sous-domaine compromis contourne SameSite mais pas un token par-intention. Laxlaisse passer les GET top-level → toute mutation en GET reste exploitable.- Méthodes « safe » : un navigateur peut envoyer le cookie sur une navigation Lax ; ne dépendez pas de SameSite pour l'autorisation.
La position staff : token CSRF = contrôle primaire, SameSite = ceinture + bretelles, CORS strict = filet pour les API. On empile, on ne substitue pas.
Observabilité & exploitation en production
- Métrique à exposer : taux de 403/419 CSRF par route. Un pic soudain = soit une attaque, soit (99% du temps) un déploiement qui a cassé l'injection du token (cache HTML d'un CDN qui sert un vieux token contre une nouvelle session).
- Cache & CSRF s'opposent : ne jamais mettre en cache (CDN, Varnish,
Cache-Control: public) une page contenant un token synchronizer — vous serviriez le même token à tout le monde, ou un token périmé. Marquez ces pagesprivate, no-store. - Log structuré sur échec :
route,token_id,referer,origin,has_cookie,has_token. Ne jamais logger la valeur du token (secret). - Faux positifs fréquents : session expirée → token orphelin → 419/403 sur un user légitime. UX recommandée : détecter, recharger le form avec un token frais (cf. scénario checkout) plutôt qu'afficher une erreur brute.
Failure modes — anatomie des pannes réelles
- Le 403 fantôme post-déploiement : CDN a mis en cache la page de formulaire. Tous les users reçoivent le même
_token, valable seulement pour la première session. Symptôme : 403 massif et intermittent. Fix :no-storesur les pages avec form, ou passer en stateless CSRF (le token ne dépend plus de la session serveur). - Le 403 en scale-out : 3 pods derrière un LB round-robin, sessions en mémoire (pas de Redis partagé). Le form est rendu par le pod A (token en session A), le POST tombe sur le pod B (session vide) → 403. Fix : session store partagé (Redis) OU stateless CSRF.
- Le double-POST Turbo : Turbo Drive recharge le form via fetch ; si le token est rendu une fois puis le DOM réutilisé, un token consommé peut être rejoué côté serveur en cas de rotation agressive. Fix :
stateless_token_ids+ ne pas rotater à chaque requête. - Le mismatch d'
id(vu plus haut) : Twig génèredelete-post-42, le contrôleur validedelete-post. 403 systématique en prod, jamais détecté en tests si les tests soumettent le form complet (le crawler récupère le bon token mais l'id de validation diverge quand même). À tester explicitement. cookie_samesite: strictcasse l'OAuth/SSO : le callback IdP → votre app est une navigation cross-site ; en Strict le cookie de session n'est pas envoyé, l'user « perd » sa session juste après le redirect. Fix :lax(suffisant car le callback est un GET top-level) ou un cookie de transition.
🏋️ Exercices
Exercice 1 — Token par-item de bout en bout (implement)
Objectif : protéger un endpoint POST /comment/{id}/delete avec un token CSRF par item validé via IsCsrfTokenValid (Expression), et prouver par test que le token du commentaire 1 est rejeté sur le commentaire 2. Indice/Solution : Twig csrf_token('delete-comment-' ~ id) ; attribute #[IsCsrfTokenValid(new Expression('"delete-comment-" ~ args["comment"].getId()'))]. Test : récupérer le form du commentaire 1, soumettre son _token sur l'URL /comment/2/delete (forger la requête à la main avec $client->request('POST', '/comment/2/delete', ['_token' => $token1])) → assert 403.
Exercice 2 — Double-submit durci avec __Host- (production-grade)
Objectif : implémenter un endpoint GET /api/csrf qui pose un cookie __Host-csrf (random_bytes(32), Secure, SameSite=Lax, Path=/) + un listener qui valide X-CSRF-Token en temps constant, et écrire un test vérifiant qu'un cookie sur un Domain parent est refusé. Indice/Solution : Cookie::create('__Host-csrf', $token)->withSecure(true)->withHttpOnly(false)->withSameSite('lax') (le préfixe __Host- interdit Domain et impose Path=/). Le JS doit pouvoir lire le cookie → httpOnly: false (c'est volontaire pour le double-submit). Listener : hash_equals. Test : injecter un cookie csrf sans préfixe et vérifier qu'il n'est pas accepté comme __Host-csrf.
Exercice 3 — Stateless CSRF pour app multi-pod (production-grade)
Objectif : configurer framework.csrf_protection.stateless_token_ids pour un form de paiement, déployer mentalement sur 3 pods sans Redis, et prouver que le form rendu par le pod A se valide sur le pod B. Indice/Solution : stateless_token_ids: [payment], csrf_token_id: 'payment'. Le token devient un cookie signé HMAC indépendant de la session → portable entre pods. Test : simuler en bootant deux kernels (ou en vérifiant que le CsrfTokenManager stateless ne lit pas la session via SessionTokenStorage).
Exercice 4 — Reproduire puis corriger le « 403 fantôme du CDN » (break-then-fix)
Objectif : construire un test qui simule un cache de la page de form (réutiliser un _token issu d'une première session sur une seconde session) et observer le 403 ; puis le corriger en passant le token id concerné en stateless. Indice/Solution : session A → récupérer _token. Nouvelle requête sans cookie de session A → POST avec l'ancien _token → 403 (synchronizer). Bascule en stateless_token_ids : le même token redevient valide car il n'est plus lié à la session. Conclusion à documenter : Cache-Control: private, no-store sur les pages avec synchronizer token.
Exercice 5 — Login CSRF (break-then-fix)
Objectif : démontrer qu'un form_login sans enable_csrf permet un login CSRF (forcer la victime à se connecter au compte de l'attaquant), puis le fermer. Indice/Solution : désactiver le CSRF du login, monter une page tierce qui auto-submit POST /login avec les creds de l'attaquant → la victime navigue désormais sous l'identité de l'attaquant (et l'attaquant lit l'historique injecté ensuite). Fix : form_login: { enable_csrf: true } + un csrf_token_id dédié authenticate. Test : POST /login sans _csrf_token → échec.
Exercice 6 — Timing oracle (break-then-fix, hardcore)
Objectif : remplacer hash_equals par === dans le listener double-submit, écrire un micro-bench qui mesure la différence de temps selon le préfixe commun, puis revenir à hash_equals et montrer que le signal disparaît. Indice/Solution : boucle de N=10^5 comparaisons avec hrtime(true) sur des tokens partageant 0 / 8 / 16 octets de préfixe. Avec ===, le temps croît avec le préfixe commun (early-exit). Avec hash_equals, plat. Discuter pourquoi en pratique le réseau noie le signal — mais que « difficile à exploiter » ≠ « sûr ».
🎤 En entretien
Q : Pourquoi un token CSRF protège-t-il, alors qu'un attaquant peut envoyer n'importe quelle requête ? R : Parce que la Same-Origin Policy empêche l'attaquant de lire une réponse de votre origine ou de lire/forger un secret lié à votre origine. Il peut déclencher la requête (avec vos cookies attachés automatiquement), mais pas connaître le token qui vit dans votre session/DOM. CSRF = « tu peux frapper à la porte mais tu n'as pas la clé secrète à présenter ».
Q : SameSite=Strict est activé. Le token CSRF devient-il inutile ? R : Non. « Site » = eTLD+1, donc un sous-domaine compromis reste same-site et contourne SameSite. La couverture navigateur n'est pas universelle (vieilles webviews), et Lax laisse passer les GET top-level. SameSite est une defense-in-depth, pas un remplacement. On empile : token (primaire) + SameSite + CORS.
Q : Une API REST stateless en JWT a-t-elle besoin de CSRF ? R : Non, si le JWT est lu par votre JS et posé manuellement dans Authorization: Bearer. Le navigateur ne l'attache pas automatiquement, donc un site tiers ne peut pas le reproduire. Mais si vous stockez le JWT dans un cookie (même HttpOnly), il redevient auto-attaché → CSRF revient, et il faut un token / double-submit / SameSite.
Q : Synchronizer token vs double-submit : lequel et pourquoi ? R : Synchronizer (secret en session) est plus robuste car le secret ne quitte jamais le serveur ; coût = état de session. Double-submit (cookie + header recopié) est stateless mais vulnérable au cookie injection depuis un sous-domaine — d'où la version durcie avec préfixe __Host- et/ou un token signé (HMAC du session-id). En multi-pod sans session partagée, je préfère le stateless signed token de Symfony plutôt qu'un double-submit maison.
Liens
- Symfony CSRF Protection — https://symfony.com/doc/current/security/csrf.html
- OWASP CSRF Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- SameSite cookies — https://web.dev/articles/samesite-cookies-explained
- Double-submit cookie pattern — OWASP CSRF guide.
- Symfony 6.4 stateless CSRF announcement — https://symfony.com/blog/new-in-symfony-6-4-stateless-csrf-protection
- "Timing attacks on string comparison" — Coda Hale.