Aller au contenu principal
own2pwn
Jeton CSRF : comprendre l'attaque, la protection et ses angles morts

Jeton CSRF : comprendre l'attaque, la protection et ses angles morts

Le jeton CSRF n'est plus le seul rempart depuis que Chrome met SameSite=Lax par défaut. On démonte l'attaque, ce que le jeton protège, ses angles morts, et le code Symfony, Django et Laravel.

own2pwn10 min de lecture

Un endpoint sans jeton CSRF, ça se voit à la revue de code

L'analyse statique SecAI marque les routes qui changent d'état sans protection anti-CSRF.

Repérer les CSRF au build

Le 17 février 2020, Chrome 80 a commencé à traiter tout cookie sans attribut SameSite comme SameSite=Lax (documentation Chromium). Du jour au lendemain, une bonne partie des attaques CSRF les plus bêtes ont cessé de fonctionner, sans que personne ne touche à une ligne de code applicatif. D'où une idée reçue tenace : le navigateur aurait réglé le problème, le jeton CSRF serait devenu du folklore. C'est faux, et le croire laisse des trous béants.

Le Cross-Site Request Forgery (CSRF, ou falsification de requête inter-sites) consiste à faire exécuter au navigateur d'une victime une action sur un site où elle est connectée, à son insu. Pas de vol de mot de passe, pas d'injection de code : on abuse simplement du fait qu'un navigateur joint automatiquement les cookies de session à chaque requête vers un domaine, y compris quand c'est une page tierce qui déclenche cette requête. Le jeton CSRF est la réponse historique à ce problème, et SameSite n'en couvre qu'une partie. Voyons précisément laquelle.

Cadre légal

Tout ce qui suit se teste sur une application que vous possédez ou dans le périmètre d'un pentest web autorisé, avec mandat écrit. Forger une requête contre un site tiers, même pour "voir si ça marche", reste un délit.

Une attaque CSRF, mécaniquement

Imaginez une banque en ligne dont le virement passe par un POST /transfer avec deux champs, to et amount. L'utilisateur est authentifié par un cookie de session que le serveur a posé à la connexion. Rien d'autre ne conditionne l'acceptation du virement. Un attaquant héberge une page anodine, y glisse un formulaire caché pointant vers cette route, et l'auto-soumet en JavaScript. Il suffit d'attirer la victime connectée sur cette page.

html
<!-- page piege.html, hebergee sur le site de l'attaquant -->
<form action="https://banque.example/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="attaquant">
  <input type="hidden" name="amount" value="5000">
</form>
<script>document.getElementById('f').submit();</script>

Quand le navigateur de la victime soumet ce formulaire, il agit comme si elle remplissait le vrai formulaire de la banque : il joint le cookie de session, et la requête part avec toutes les autorisations de la victime. Côté serveur, rien ne distingue cette requête d'une action légitime. Le virement passe. C'est tout le paradoxe de la CSRF : l'attaquant ne voit jamais la réponse (la same-origin policy la lui cache), mais il n'en a pas besoin : l'effet de bord, le virement, a déjà eu lieu.

flux-csrf
Attaquant
Page piégée
Un formulaire caché pointe vers banque.example/transfer, auto-soumis au chargement.
Navigateur de la victime
Émet la requête
Joint automatiquement le cookie de session de banque.example.
Serveur
Ne voit rien d'anormal
Cookie valide, session active : la requête est traitée comme légitime.
La victime authentifiée est le vecteur : c'est son navigateur, avec ses cookies, qui émet la requête forgée.

Trois conditions rendent l'attaque possible : l'action change un état (elle n'est pas qu'une lecture), elle repose sur un cookie envoyé automatiquement, et tous ses paramètres sont prévisibles par l'attaquant. Cassez n'importe laquelle des trois et l'attaque tombe. Le jeton CSRF s'attaque à la troisième.

Le jeton CSRF : ce qu'il protège vraiment

Le principe, appelé synchronizer token pattern dans la fiche OWASP sur la prévention CSRF, est d'introduire un paramètre imprévisible dans chaque requête sensible. Le serveur génère par session une valeur secrète, aléatoire, et l'insère dans le formulaire au moment du rendu. À la soumission, il vérifie que la requête porte bien cette valeur. Une page tierce n'a aucun moyen de la lire (la same-origin policy l'en empêche) ni de la deviner : sa requête forgée arrive sans le bon jeton, et le serveur la rejette.

validation-jeton
Requête sensible
Renvoie le jeton
Le serveur compare : identique, on traite ; absent ou faux, on rejette en 403.
Page légitime
Reçoit le jeton
Injecté en champ caché ou en balise meta.
Serveur
Génère + stocke le jeton
Valeur aléatoire liée à la session, régénérée à chaque nouvelle session.
Le jeton casse la prévisibilité de la requête : l'attaquant contrôle to et amount, jamais le jeton de session.

Un bon jeton est secret, unique par session et imprévisible, insiste OWASP. Il ne transite jamais dans une URL en GET (elle finit dans les logs, l'historique, l'en-tête Referer) et il n'a de sens que sur les verbes qui modifient un état. Corollaire non négociable de la même fiche : aucune opération sensible ne doit passer par une requête GET. Un endpoint GET /delete?id=42 reste forgeable via une simple balise <img>, jeton ou pas.

SameSite par défaut : la moitié du travail, pas tout

L'attribut de cookie SameSite agit sur la deuxième condition (le cookie envoyé automatiquement) plutôt que sur la prévisibilité. Il prend trois valeurs. Strict ne joint le cookie que dans un contexte strictement first-party : même en cliquant un lien vers votre site depuis un autre, le cookie ne part pas au premier chargement. Lax, plus souple, laisse passer le cookie sur les navigations de premier niveau en méthode sûre (un clic de lien, donc un GET), mais le bloque sur les requêtes inter-sites comme un POST de formulaire ou un chargement d'image. None envoie le cookie partout et exige l'attribut Secure.

samesite
Requête inter-sitesStrictLaxNone
Clic sur un lien (navigation GET)NonOuiOui
POST auto-soumis (formulaire)NonNonOui
<img>, <iframe>, fetchNonNonOui
Navigation same-siteOuiOuiOui
Ce que chaque valeur laisse passer depuis un site tiers. Lax bloque le POST forgé mais pas le GET de premier niveau.

C'est le passage de "pas d'attribut = None de fait" à "pas d'attribut = Lax" dans Chrome 80 qui a désarmé tant d'attaques d'un coup : le formulaire auto-soumis de notre banque, en POST, n'emporte plus le cookie par défaut. Mais OWASP est clair, SameSite est une défense en profondeur, pas un rempart autonome. Trois raisons : Lax laisse toujours passer un GET de premier niveau, donc tout endpoint qui change un état en GET reste exposé ; les navigateurs anciens n'appliquent rien ; et un cookie posé avec SameSite sur le domaine parent reste "same-site" pour tous ses sous-domaines. Cette dernière fissure mérite qu'on s'y arrête.

Quand le jeton CSRF ne protège pas

La protection anti-CSRF n'est pas absolue, et un pentester passe justement son temps à en chercher les bords. Trois situations où le jeton, ou SameSite, tombe.

  • Un sous-domaine compromis : les cookies sont partagés entre sous-domaines d'un même domaine enregistrable. Si blog.example.com est pris par un attaquant, il se retrouve dans le même contexte "same-site" que app.example.com et contourne SameSite. Un subdomain takeover transforme alors un sous-domaine oublié en tremplin CSRF. La variante double-submit cookie naïve y est particulièrement fragile, car le sous-domaine peut écrire le cookie qui sert de référence.
  • Une XSS déjà présente : si l'attaquant exécute du JavaScript dans l'origine de la victime, il lit le jeton dans le DOM et l'ajoute à sa requête. Une faille de type Cross-Site Scripting annule par construction toute protection CSRF, ce qui explique pourquoi les deux figurent au OWASP Top 10 et se traitent ensemble.
  • Une exemption trop large : chaque framework permet de désactiver la vérification sur certaines routes (webhooks Stripe, API machine à machine). Une exemption posée au niveau d'un préfixe entier, ou un csrf_exempt laissé sur un endpoint qui a fini par changer d'état, rouvre le trou en silence.

Le double-submit signé, pas le double-submit naïf

Le patron double-submit cookie (le même jeton dans un cookie et dans le formulaire, le serveur comparant les deux) évite de stocker un état côté serveur. Mais sa version naïve se fait injecter le cookie par un sous-domaine. OWASP recommande la variante signée en HMAC, qui lie le jeton à la session authentifiée et bloque l'injection de cookie.

Jeton CSRF en Symfony, Django et Laravel

Aucun de ces frameworks ne vous demande d'écrire la vérification à la main : elle est activée par défaut sur les formulaires. Le piège n'est pas de l'implémenter, c'est de la désactiver sans le vouloir. Voici la syntaxe actuelle des trois, vérifiée dans leur documentation courante.

Symfony

En Symfony 8, on génère le jeton en Twig avec csrf_token() et un identifiant arbitraire, puis on le valide dans le contrôleur avec isCsrfTokenValid() ou l'attribut #[IsCsrfTokenValid]. Les formulaires du composant Form l'intègrent seuls.

twig
{# template Twig #}
<form action="{{ url('post_delete', { id: post.id }) }}" method="post">
    <input type="hidden" name="token" value="{{ csrf_token('delete-item') }}">
    <button>Supprimer</button>
</form>
php
// controleur Symfony 8
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;

#[IsCsrfTokenValid('delete-item', tokenKey: 'token')]
public function delete(Post $post): Response
{
    // le jeton est verifie AVANT d'entrer ici ; sinon, 403
    $this->repository->remove($post);
    return $this->redirectToRoute('post_index');
}

Django

Django protège via CsrfViewMiddleware, actif par défaut. Dans le gabarit, la balise {% csrf_token %} insère un champ caché csrfmiddlewaretoken. Le middleware compare ce champ (ou l'en-tête X-CSRFToken en AJAX) au secret du cookie, et vérifie l'Origin contre CSRF_TRUSTED_ORIGINS. Le décorateur @csrf_exempt est justement ce qu'on cherche à l'audit.

html
<!-- gabarit Django 5 -->
<form method="post">
    {% csrf_token %}
    <input type="email" name="email">
    <button>Mettre a jour</button>
</form>
python
# a chasser en revue : une exemption laissee sur une vue qui change d'etat
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt          # <-- desactive toute la protection CSRF
def update_email(request):
    request.user.email = request.POST["email"]
    request.user.save()
    return HttpResponse("ok")

Laravel

En Laravel 12, la directive Blade @csrf pose le champ caché _token, et le middleware ValidateCsrfToken (dans le groupe web) le compare au jeton de session. Les exclusions se déclarent dans bootstrap/app.php via validateCsrfTokens(except: [...]), en général pour des webhooks. Une glob trop large ici, et des routes légitimes tombent sans filet.

php
{{-- vue Blade Laravel 12 --}}
<form method="POST" action="/profile">
    @csrf
    <input type="email" name="email">
    <button>Enregistrer</button>
</form>

// bootstrap/app.php : n'exempter QUE ce qui doit l'etre
->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: [
        'stripe/*',   // webhook signe par Stripe, pas de cookie
    ]);
})

Le point commun ? La protection est là par défaut, et les incidents naissent presque toujours d'une désactivation. C'est exactement le genre de motif qu'une revue de code de sécurité ou une passe d'analyse statique appliquée à la sécurité remonte : une route qui écrit en base derrière un csrf_exempt, un verbe GET qui supprime, une exemption par glob trop gourmande.

Reproduire une CSRF sur une application de démo

Pour manipuler une CSRF sans viser personne, il faut une cible qu'on possède. OWASP Juice Shop est l'application volontairement vulnérable de référence, et elle apprend d'emblée une nuance utile : elle authentifie par un jeton JWT placé dans un en-tête Authorization: Bearer que son propre JavaScript ajoute. Le navigateur n'ajoutant jamais cet en-tête de lui-même sur une requête inter-sites, Juice Shop est immune à la CSRF classique par construction. C'est la meilleure démonstration de la règle : la CSRF ne vise que les authentifications par cookie automatique.

Pour voir l'attaque aboutir, on prend donc une application de démo authentifiée par cookie de session, sans jeton (par exemple un lab monté à cet effet, ou une instance Juice Shop reconfigurée en session cookie). La méthode de test tient en deux temps. D'abord, on capture une requête sensible avec un proxy comme Burp et on la rejoue sans le jeton ni l'Origin. Si le serveur répond 200, la route est forgeable.

http
# Requete forgee, capturee au proxy : ni jeton, ni en-tete Origin
POST /profile/email HTTP/1.1
Host: demo.local
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJ1c2VyIjo0Mn0...
Content-Length: 27

email=attaquant%40evil.test

Ensuite, on matérialise l'exploitation avec la page HTML auto-soumise vue plus haut, ouverte dans un navigateur où l'on est connecté à la démo. Le cookie session part tout seul, l'e-mail est changé, et la preuve tient dans la requête sortante : pas de jeton, un Referer pointant vers le domaine de l'attaquant, et un serveur qui a quand même traité l'action. Ajoutez le jeton CSRF dans le formulaire de la démo, relancez : la requête forgée repart en 403. C'est cette bascule qu'on cherche à démontrer à un client, et c'est le genre de test qu'on mène en pentest web blackbox.

"Jeton CSRF invalide" : si vous êtes un utilisateur bloqué

Si vous avez atterri ici parce qu'un site vous répond "jeton CSRF invalide" (ou "CSRF token mismatch", "erreur 419") au moment de valider un formulaire, vous n'êtes pas attaqué et vous n'avez rien cassé. Ce message est une sécurité qui se déclenche quand le serveur ne retrouve plus le jeton attendu : le plus souvent, votre session a expiré parce que la page est restée ouverte trop longtemps avant l'envoi. Les causes typiques :

  • Page laissée ouverte trop longtemps : le jeton et la session ont expiré entre l'affichage du formulaire et son envoi. Rechargez la page pour en obtenir un frais, puis renvoyez.
  • Cookies bloqués : sans cookie de session, le serveur n'a aucun jeton auquel comparer. Autorisez les cookies du site, désactivez le mode privé strict ou l'extension qui les filtre, puis réessayez.
  • Retour arrière du navigateur : revenir sur une page en cache réaffiche un ancien jeton, déjà consommé. Rechargez plutôt que d'utiliser le bouton précédent.
  • Horloge de la machine déréglée : une date système fausse fait expirer cookies et jetons prématurément. Remettez l'heure à l'heure.

Dans la quasi-totalité des cas, recharger la page puis resoumettre suffit. Si le message persiste sur tous les sites, videz les cookies de votre navigateur. Ce n'est pas un signe d'intrusion : c'est la protection anti-CSRF qui fait exactement son travail, un peu trop bien pour votre confort du moment.

À retenir

  • Une attaque CSRF force le navigateur d'une victime à émettre une requête qui change un état, en abusant du cookie de session envoyé automatiquement. L'attaquant ne lit jamais la réponse, il déclenche l'effet de bord.
  • Le jeton CSRF (synchronizer token) casse la prévisibilité de la requête : valeur secrète par session, dans le formulaire ou un en-tête, jamais en GET. C'est la défense principale.
  • SameSite=Lax par défaut depuis Chrome 80 bloque le POST forgé mais laisse passer les GET de premier niveau et ne couvre pas un sous-domaine compromis. C'est un complément, pas un remplaçant.
  • Le jeton ne protège pas contre une XSS présente, un subdomain takeover, ou une exemption trop large ; préférez le double-submit signé en HMAC au double-submit naïf.
  • En Symfony, Django et Laravel, la protection est active par défaut : les incidents naissent d'un csrf_exempt ou d'une exemption par glob oubliée. C'est ce que traque une revue de code.
  • Le message "jeton CSRF invalide" côté utilisateur n'est pas une attaque : recharger la page et vérifier les cookies suffit presque toujours.

La CSRF est une faille de configuration autant que de code : elle se glisse dans l'endpoint qu'on a exempté "juste pour le webhook" et qui a fini par changer d'état. La repérer, c'est confronter chaque route qui écrit à sa protection réelle. C'est ce que fait l'analyse statique SecAI au fil des commits, et ce qu'on vérifie à la main sur une application entière en pentest web. Un doute sur un endpoint précis ? Parlez-en via la page contact.

Questions fréquentes sur le jeton CSRF

Qu'est-ce qu'un jeton CSRF ?

C'est une valeur secrète et imprévisible que le serveur génère par session et exige sur chaque requête qui modifie un état (POST, PUT, DELETE). Il est déposé dans le formulaire ou un en-tête, jamais dans un simple cookie exploitable. Un site tiers ne peut pas deviner cette valeur, donc une requête forgée depuis un autre domaine arrive sans le bon jeton et se fait rejeter par le serveur.

Le jeton CSRF est-il encore utile avec SameSite par défaut ?

Oui. Depuis Chrome 80 en 2020, un cookie sans attribut SameSite est traité comme Lax, ce qui bloque déjà les requêtes POST inter-sites. Mais Lax laisse passer les navigations GET de premier niveau, ne protège pas si un sous-domaine du site est compromis, et n'est pas appliqué par les navigateurs anciens. Le jeton reste la défense principale, SameSite venant en complément.

Pourquoi j'ai le message jeton CSRF invalide ?

Le plus souvent, votre session a expiré ou vos cookies sont bloqués : le serveur ne retrouve plus le jeton attendu. Le formulaire laissé ouvert trop longtemps, un retour arrière dans le navigateur, ou une extension qui filtre les cookies produisent le même effet. Rechargez la page pour obtenir un jeton frais, vérifiez que les cookies du site sont autorisés, puis renvoyez le formulaire.

Quelle différence entre XSS et CSRF ?

Une XSS injecte et exécute du code dans la page de la victime : l'attaquant lit et modifie tout, y compris le jeton CSRF. Une CSRF ne lit rien, elle force juste le navigateur de la victime à émettre une requête vers un site où elle est connectée. La CSRF exploite la confiance du serveur envers le cookie ; une XSS présente casse par définition toute protection CSRF.

Comment tester une vulnérabilité CSRF ?

Interceptez une requête qui change un état, retirez le jeton et l'en-tête Origin, puis rejouez-la : si le serveur l'accepte, l'endpoint est vulnérable. Construisez ensuite une page HTML qui soumet automatiquement le formulaire vers la cible et ouvrez-la dans un navigateur où vous êtes connecté. Cela ne se fait que sur une application que vous possédez ou dans le périmètre d'un pentest autorisé.

Une API REST avec jeton Bearer a-t-elle besoin de protection CSRF ?

Non, si l'authentification passe uniquement par un en-tête Authorization: Bearer que le code JavaScript ajoute lui-même. Le navigateur n'ajoute pas cet en-tête automatiquement sur une requête inter-sites, donc une page tierce ne peut pas l'usurper. La CSRF classique ne vise que les authentifications par cookie envoyé automatiquement. Une API qui s'appuie sur un cookie de session reste, elle, concernée.

Veille sécurité

La suite, une fois par mois

Ce qui bouge vraiment sur la surface d'attaque externe, NIS2 et la sécurité applicative, écrit par le pentester qui signe ces articles. Un envoi par mois, désinscription en un clic.

Votre adresse ne sert qu'à cet envoi. Voir la politique de confidentialité.

Articles liés