Web Application Security
Chapitre 4 / 9 · 12 min de lecture

Le Cross-Site Request Forgery (CSRF)

Faire exécuter une action à une victime à son insu — et s'en protéger avec des jetons anti-CSRF et SameSite.

Imaginez un utilisateur connecté à sa banque dans un onglet. Dans un autre onglet, il consulte un forum, un e-mail ou une page apparemment anodine. Sans qu'il clique sur quoi que ce soit d'évident, son navigateur émet une requête vers la banque — et de l'argent change de compte. L'utilisateur n'a rien remarqué, et pourtant l'opération a bien eu lieu en son nom. C'est tout le mécanisme de la falsification de requête inter-sites (Cross-Site Request Forgery, CSRF).

Le CSRF n'exploite pas une faille de votre logique métier au sens classique : il exploite la relation de confiance entre un navigateur, un utilisateur et un serveur. Par défaut, le navigateur joint automatiquement les cookies (donc l'identité de session) à toute requête envoyée vers un domaine, peu importe d'où part cette requête. Un site tiers piégé peut donc déclencher des requêtes vers votre application en profitant de la session déjà ouverte de la victime. Ce chapitre explique d'abord le mécanisme, de façon conceptuelle, puis consacre l'essentiel du propos à ce qui compte vraiment pour vous : comment bâtir une application qui y résiste.

Note

Ce blog est éducatif et défensif. Comprendre une attaque sert à écrire du code qui la rend impossible. Ne testez jamais ces techniques que sur des systèmes que vous possédez ou pour lesquels vous avez une autorisation écrite, et pratiquez la divulgation responsable.

Comment ça marche (conceptuellement)

Hoffman identifie deux signatures qui caractérisent une attaque CSRF :

  • une élévation de privilèges (privilege escalation) : l'attaquant ne peut pas atteindre un point d'entrée privilégié, mais la victime, elle, le peut ;
  • une attaque furtive : le compte qui initie la requête ne sait généralement pas qu'elle a eu lieu, car tout se passe en arrière-plan.

Le point central est que le navigateur fait confiance par défaut : il considère que toute action partant de l'appareil de l'utilisateur est faite au nom de cet utilisateur, et il y attache les données d'authentification (cookies de session). Le CSRF détourne précisément cette confiance.

Altération de paramètres via un lien GET

La forme la plus simple repose sur l'altération de paramètres de requête (query parameter tampering). Une requête HTTP GET porte ses paramètres directement dans l'URL :

GET /transfer?to_user=123&amount=1000 HTTP/1.1
Host: www.mega-bank.com

Or beaucoup d'éléments du web déclenchent un GET : la barre d'adresse, mais surtout un lien « <a> ». Un lien comme celui-ci s'affiche simplement « Mon site » à l'écran, masquant entièrement les paramètres transportés :

<a href="https://www.my-website.com?id=123">Mon site</a>

Le danger apparaît dès qu'un point d'entrée modifie l'état du serveur via GET. Voici le contre-exemple emblématique du livre, un virement bancaire exposé en GET :

// ❌ VULNÉRABLE : une action sensible exposée en HTTP GET.
app.get('/transfer', function (req, res) {
  if (!session.isAuthenticated) { return res.sendStatus(401); }
  if (!req.query.to_user) { return res.sendStatus(400); }
  if (!req.query.amount) { return res.sendStatus(400); }

  transferFunds(session.currentUser, req.query.to_user,
    req.query.amount, (error) => {
      if (error) { return res.sendStatus(400); }
      return res.json({ operation: 'transfer', status: 'complete' });
    });
});

À l'œil non averti, ce code paraît correct : il vérifie l'authentification, il vérifie la présence d'un destinataire et d'un montant. Mais comme l'action passe par un GET, il suffit d'un lien pour déclencher un virement chez n'importe quelle victime authentifiée :

<!-- Un lien piégé, distribué par e-mail ou réseau social. -->
<a href="https://www.mega-bank.com/transfer?to_user=attaquant&amount=10000">
  Cliquez ici
</a>

Charges automatiques : l'image et les balises à src

Pire encore, certaines balises déclenchent un GET sans aucune interaction. Une balise « <img> » émet une requête vers son attribut src dès qu'elle est chargée dans le DOM :

<!-- L'image se charge seule : aucun clic requis de la victime. -->
<img src="https://www.mega-bank.com/transfer?to_user=attaquant&amount=10000"
     width="0" height="0" border="0">

Une image invisible de 0 × 0 pixel suffit. Le même principe vaut pour toute balise acceptant une URL : « <video> », « <source> », et plus largement tout élément possédant un attribut src. La leçon est claire : toute action qui change l'état exposée en GET est trivialement attaquable.

CSRF contre des points d'entrée POST

Passer en POST n'élimine pas le risque, mais relève la barre. Le formulaire HTML est l'un des rares éléments capables d'émettre un POST sans script. Une page piégée peut en héberger un, garni de champs cachés, et le faire soumettre :

<!-- ❌ Un formulaire piégé ciblant un autre domaine. -->
<form action="https://www.mega-bank.com/transfer" method="POST">
  <input type="hidden" name="to_user" value="attaquant">
  <input type="hidden" name="amount" value="10000">
  <input type="submit" value="Soumettre">
</form>

L'attaquant peut même habiller le piège avec de vrais champs (par exemple un faux formulaire de connexion) pour pousser la victime à valider. Cette technique permet aussi de faire émettre des requêtes vers un serveur interne : l'auteur du formulaire n'y a pas accès, mais une victime située sur le réseau interne, elle, l'atteindra avec ses propres privilèges réseau.

Attention

La règle à graver : une requête qui modifie l'état du serveur ne doit jamais passer par GET. Un GET doit être idempotent et sans effet de bord. Dès qu'un GET crée, met à jour ou supprime, vous l'exposez aux formes de CSRF les plus faciles à distribuer (lien, image, balise src).

Comment se défendre

Hoffman insiste : aucune défense isolée n'est suffisante. Le maillon le plus faible casse la chaîne. On combine donc plusieurs couches, dont la plus puissante reste le jeton anti-CSRF. Passons-les en revue, de la plus structurante à la plus complémentaire.

Règle d'or : ne jamais modifier l'état via GET

C'est la mesure la plus fondamentale, car elle supprime d'un coup la catégorie d'attaque la plus simple. Un GET ne doit rien stocker ni modifier côté serveur. Comparez ces deux conceptions :

// ❌ VULNÉRABLE : un GET qui modifie l'utilisateur au passage.
// Atteignable par un simple lien ou une image piégée :
//   https://site/user?id=123&updates=email:attaquant
const user = function (req, res) {
  getUserById(req.query.id).then((user) => {
    if (req.query.updates) { user.update(req.query.updates); }
    return res.json(user);
  });
};
// ✅ CORRIGÉ : lecture et écriture séparées par verbe HTTP.
// GET : lecture pure, aucun effet de bord.
const getUser = function (req, res) {
  getUserById(req.query.id).then((user) => res.json(user));
};

// POST : la modification d'état exige un verbe non-GET.
const updateUser = function (req, res) {
  getUserById(req.query.id).then((user) => {
    user.update(req.body.updates).then((updated) => {
      if (!updated) { return res.sendStatus(400); }
      return res.sendStatus(200);
    });
  });
};

La version corrigée n'est pas encore parfaitement protégée (un POST reste attaquable par formulaire piégé), mais elle est immunisée contre les liens, images et autres CSRF en GET. Les opérations d'état doivent donc utiliser POST, PUT ou DELETE — jamais GET — et être protégées par jeton, comme nous le voyons plus bas.

Le jeton anti-CSRF : la défense maîtresse

Le jeton anti-CSRF (CSRF token), aussi appelé motif du jeton synchroniseur (synchronizer token pattern), est selon Hoffman « la forme de défense la plus puissante ». La plupart des grands sites en font leur défense principale. Le principe :

  1. Le serveur génère un jeton imprévisible, produit cryptographiquement avec une probabilité de collision infime, et lié à la session de l'utilisateur (régénéré par session, parfois par requête). Il l'envoie au client.
  2. Chaque requête renvoie ce jeton — dans les formulaires comme dans les requêtes AJAX. À réception, le serveur vérifie qu'il est authentique, non expiré et non altéré. En cas d'échec, la requête est journalisée et rejetée.
  3. Conséquence : une attaque venue d'un autre domaine devient extrêmement difficile. Un site tiers ne peut pas deviner le jeton (il est unique par session et par utilisateur). L'attaquant devrait cibler un utilisateur précis avec un jeton vivant et à jour — et l'expiration fait que le jeton est souvent déjà mort quand la victime clique sur le lien piégé.

Voici l'évolution d'un formulaire, avant et après protection :

<!-- ❌ AVANT : formulaire sans aucune protection anti-CSRF. -->
<form action="https://www.mega-bank.com/transfer" method="POST">
  <input type="text" name="to_user">
  <input type="number" name="amount">
  <input type="submit" value="Transférer">
</form>
<!-- ✅ APRÈS : un jeton imprévisible, lié à la session, est inséré. -->
<form action="https://www.mega-bank.com/transfer" method="POST">
  <input type="hidden" name="csrf_token"
         value="a1b2c3d4-...-imprevisible-par-session">
  <input type="text" name="to_user">
  <input type="number" name="amount">
  <input type="submit" value="Transférer">
</form>

Côté serveur, on rejette toute requête dont le jeton est absent, faux ou expiré :

// ✅ Vérification du jeton avant toute logique métier.
const transfer = function (req, res) {
  if (!session.isAuthenticated) { return res.sendStatus(401); }
  if (!isValidCsrfToken(req.body.csrf_token, session.currentUser)) {
    logger.log(req);            // tentative journalisée
    return res.sendStatus(401);
  }
  return transferFunds(session.currentUser,
    req.body.to_user, req.body.amount);
};

À retenir

Le jeton fonctionne parce qu'il repose sur quelque chose qu'un domaine tiers ne peut ni lire ni prédire. Il doit donc être : généré côté serveur, imprévisible (cryptographiquement aléatoire), lié à la session, à durée de vie limitée, et vérifié à chaque requête qui modifie l'état. Un jeton qui n'est pas vérifié côté serveur ne protège de rien.

Jetons CSRF en contexte sans état (API REST)

Avec l'essor des API REST, les serveurs sont souvent sans état (stateless) : ils ne tiennent plus de registre des clients connectés. Hoffman souligne qu'il ne faut pas sacrifier ce choix d'architecture pour ajouter des jetons. On rend simplement le jeton auto-porteur grâce au chiffrement. Un jeton CSRF sans état contient :

  • un identifiant unique de l'utilisateur auquel il appartient ;
  • un horodatage (timestamp) servant à l'expiration ;
  • un nonce cryptographique (cryptographic nonce) dont la clé n'existe que sur le serveur.

Le serveur peut alors valider le jeton sans stocker d'état, ce qui passe mieux à l'échelle que la gestion de sessions. Pour les API consommées par du JavaScript, le jeton voyage typiquement dans un en-tête HTTP (par exemple X-CSRF-Token) plutôt que dans un cookie — point sur lequel nous revenons avec SameSite.

Puisque le CSRF repose sur l'envoi automatique du cookie de session vers votre domaine, l'attribut SameSite du cookie attaque le problème à la racine : il indique au navigateur de ne pas joindre le cookie aux requêtes inter-sites.

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax

Deux valeurs principales :

  • SameSite=Lax — désormais la valeur par défaut des navigateurs modernes. Le cookie n'est pas envoyé sur les requêtes inter-sites, à l'exception des navigations de haut niveau en GET (clic sur un lien). Cela neutralise les CSRF par formulaire POST et par image, tout en préservant l'ergonomie usuelle.
  • SameSite=Strict — le cookie n'est jamais envoyé depuis un autre site, même sur un clic de lien. Protection maximale, à réserver aux opérations les plus sensibles (le compromis : un lien externe vers votre site arrive « déconnecté »).

Piège courant

SameSite est une excellente défense en profondeur, mais ne vous en remettez pas à elle seule. Elle dépend du navigateur de la victime ; surtout, elle ne protège pas les API qui s'authentifient par jeton dans un en-tête plutôt que par cookie. Combinez toujours SameSite (côté transport) avec un jeton anti-CSRF (côté application).

Vérification des en-têtes Origin et Referer

Parce que la plupart des requêtes CSRF proviennent d'un domaine différent du vôtre, vous pouvez inspecter deux en-têtes qui révèlent l'origine d'une requête, et qui — point clé — ne sont pas modifiables par JavaScript dans les grands navigateurs :

  • Origin : présent notamment sur les requêtes POST, en HTTP comme en HTTPS. Forme : Origin: https://www.mega-bank.com.
  • Referer : présent sur la plupart des requêtes, sauf lorsque le lien porte rel="noreferrer". Forme : Referer: https://www.mega-bank.com.

On compare ces valeurs à une liste d'origines de confiance :

// ✅ Première ligne de défense : valider l'origine.
const validLocations = [
  'https://www.mega-bank.com',
  'https://api.mega-bank.com',
  'https://portal.mega-bank.com',
];

const validateHeaders = function (headers, method) {
  const { origin, referer } = headers;
  if (method === 'POST') {
    return validLocations.includes(origin) &&
           validLocations.includes(referer);
  }
  return validLocations.includes(referer);
};

Quand c'est possible, vérifiez les deux en-têtes ; si aucun n'est présent, considérez la requête comme non standard et rejetez-la.

Attention

La vérification d'en-têtes a une limite sérieuse. Si un attaquant obtient une faille XSS sur l'une de vos origines autorisées, il peut lancer l'attaque depuis votre propre domaine : la requête semblera alors parfaitement légitime. C'est encore plus préoccupant si votre site accepte du contenu généré par les utilisateurs. Traitez donc cette mesure comme un point de départ, jamais comme une solution complète.

Ré-authentification pour les actions sensibles

Pour les opérations les plus critiques (virement, changement de mot de passe ou d'e-mail, suppression de compte), exigez une ré-authentification : redemander le mot de passe, un code à usage unique ou une confirmation forte juste avant l'action. Une attaque CSRF peut faire émettre une requête au navigateur, mais elle ne connaît pas le secret que la victime devra ressaisir à l'instant T. Cette barrière interactive coupe l'aspect furtif de l'attaque.

Une défense à l'échelle de l'application : le middleware

Toutes ces techniques ne valent que si elles sont appliquées partout. La bonne pratique est de centraliser les contrôles dans un intergiciel (middleware) qui s'exécute avant chaque route, et qui vérifie en une fois les en-têtes puis le jeton :

// ✅ Bouclier CSRF appliqué à toutes les routes d'état.
const CSRFShield = function (req, res, next) {
  const headersOk = validateHeaders(req.headers, req.method);
  const tokenOk = validateCSRFToken(req.body.csrf_token,
    session.currentUser);

  if (!headersOk || !tokenOk) {
    logger.log(req);          // on journalise la tentative
    return res.sendStatus(401);
  }
  return next();              // sinon, on poursuit normalement
};

Côté client, automatisez l'injection du jeton pour ne jamais l'oublier : enveloppez vos appels réseau (par exemple en encapsulant fetch/XMLHttpRequest) afin d'attacher le jeton à chaque requête modifiant l'état, en fonction du verbe HTTP. La cohérence d'application est ce qui transforme une bonne idée en protection réelle.

Tableau récapitulatif des contre-mesures

Menace / vecteurContre-mesureEffet
Action d'état exposée en GET (lien, image, src)Réserver POST/PUT/DELETE aux écritures ; GET sans effet de bordSupprime les CSRF les plus faciles à distribuer
Formulaire POST piégé sur un site tiersJeton anti-CSRF imprévisible, lié à la session, vérifié serveurLe tiers ne peut pas deviner le jeton
Cookie de session joint aux requêtes inter-sitesSameSite=Lax (défaut) ou StrictLe cookie n'est pas envoyé depuis un autre site
Requête venue d'un domaine externeVérification des en-têtes Origin / RefererPremière ligne de filtrage (faillible si XSS)
Opération critique déclenchée à l'insu de la victimeRé-authentification (mot de passe, code à usage unique)Coupe la furtivité de l'attaque
Application sans état (API REST)Jeton CSRF chiffré auto-porteur, dans un en-tête HTTPProtège sans casser l'architecture stateless
Couverture partielle des routesMiddleware anti-CSRF appliqué globalementÉvite le maillon faible oublié

Pourquoi le CSRF persiste

Au fond, Hoffman le rappelle, le CSRF découle du modèle de confiance défini par les comités de standardisation du web (comme le WHATWG) : le navigateur suppose que toute action partant de l'appareil est consentie. Tant que ce modèle perdure, ces attaques restent possibles. Les évolutions récentes — SameSite=Lax par défaut en tête — réduisent fortement la surface, mais elles ne dispensent pas d'une défense applicative explicite. La bonne nouvelle pour le développeur : les contre-mesures sont simples, connues et combinables. Bien appliquées, elles rendent le CSRF impraticable.

À retenir

  • Le CSRF exploite la confiance du navigateur : les cookies de session partent automatiquement vers votre domaine, même depuis une page tierce piégée.
  • Règle d'or : aucune action qui modifie l'état ne doit passer par GET. Réservez les écritures à POST/PUT/DELETE et gardez les GET sans effet de bord.
  • Le jeton anti-CSRF est la défense maîtresse : imprévisible, lié à la session, à durée limitée, vérifié côté serveur à chaque requête d'état. Un tiers ne peut pas le deviner.
  • SameSite (Lax par défaut, Strict si besoin) empêche l'envoi du cookie sur les requêtes inter-sites — excellente défense en profondeur, mais à compléter, jamais à isoler.
  • Origin/Referer et ré-authentification ajoutent des couches utiles ; la vérification d'en-têtes reste faillible en présence d'une XSS.
  • Appliquez tout, partout : centralisez les contrôles dans un middleware, automatisez l'injection du jeton côté client, et pour les API sans état, faites voyager un jeton chiffré dans un en-tête HTTP plutôt que dans un cookie.