CSRF : pourquoi le double-submit cookie ne suffit pas pour du financial-grade

Dans les articles précédents, on a blindé le login : timing constant, lockout progressif, pas de leak d'existence. L'utilisateur est authentifié. Maintenant, la question c'est : comment protéger les actions qu'il fait une fois connecté ?

CSRF. Le sujet que tout le monde pense maîtriser parce qu'il a mis un token dans un hidden field en 2015. Sauf que le double-submit cookie — le pattern qu'on trouve dans 80% des implémentations — a des faiblesses que la plupart des devs ignorent.

Le double-submit cookie fonctionne ainsi : le serveur set un cookie csrf_token=abc123, le formulaire envoie le même token dans un hidden field, le serveur compare les deux. Si un attaquant ne peut pas lire les cookies du domaine, il ne peut pas forger la requête.

Le problème : l'attaquant n'a pas besoin de lire le cookie. Il a besoin de le setter. Et dans plusieurs scénarios, c'est possible :

  • Sous-domaine compromis — un XSS sur blog.example.com permet de setter un cookie pour .example.com
  • HTTP downgrade — si une seule page du domaine est accessible en HTTP (même un redirect), un attaquant MITM peut injecter un Set-Cookie
  • Cookie tossing — certains navigateurs acceptent des cookies settés par des domaines parents

Si l'attaquant peut setter le cookie CSRF, il contrôle les deux valeurs (cookie et form field) et le double-submit ne protège plus rien.

Pour un service d'authentification qui gère des opérations sensibles, ce n'est pas acceptable. L'OWASP ASVS V4.2.2 recommande explicitement le synchronizer token pattern pour les contextes à haut risque.

Synchronizer token : stockage server-side

Le synchronizer token est simple : le token CSRF est généré par le serveur, stocké dans la session (DB ou Redis), et comparé en constant-time à chaque requête mutative.

// Generation du token a la creation de session
func generateCSRFToken() string {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        panic(err) // crypto/rand failure = arret immediat
    }
    return base64.RawURLEncoding.EncodeToString(b)
}

// Stockage dans la session
session.CSRFToken = generateCSRFToken()
store.Save(session)

La comparaison utilise subtle.ConstantTimeCompare — pas == — pour éviter un timing oracle sur le token :

func validateCSRF(session *Session, provided string) bool {
    expected := []byte(session.CSRFToken)
    got := []byte(provided)
    return subtle.ConstantTimeCompare(expected, got) == 1
}

L'avantage : le token n'est jamais dans un cookie. L'attaquant ne peut ni le lire ni le setter. La seule façon de l'obtenir est d'avoir accès au DOM de la page (et à ce stade, c'est un XSS, pas un CSRF).

Le wire-order du middleware

L'ordre dans lequel tu chaînes tes middlewares n'est pas cosmétique. Pour le CSRF, le middleware doit s'exécuter :

  1. Après le session-load — sinon tu n'as pas le token à comparer
  2. Avant le refresh-user — sinon une requête CSRF forgée pourrait déclencher un refresh qui a des side-effects
// Ordre correct
mux.Use(sessionMiddleware)    // 1. charge la session
mux.Use(csrfMiddleware)       // 2. valide le CSRF token
mux.Use(refreshUserMiddleware) // 3. rafraichit les donnees user
mux.Use(handler)              // 4. traite la requete

J'ai vu une codebase où le CSRF middleware était après le refresh-user. Résultat : une requête CSRF forgée déclenchait un SELECT + UPDATE last_seen sur l'utilisateur avant d'être rejetée. Pas une vuln en soi, mais un side-effect inutile et un signal que le wire-order n'a pas été pensé.

Header X-CSRF-Token pour les requêtes JS

Les formulaires HTML envoient le token dans un hidden field. Mais les requêtes fetch() ou XMLHttpRequest ne passent pas par un formulaire. Pour celles-ci, le pattern standard : header custom X-CSRF-Token.

// Cote client : lire le token depuis une meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

fetch('/api/action', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken,
    },
    body: JSON.stringify(data),
});
// Cote serveur : accepter le token depuis le header OU le form field
func extractCSRFToken(r *http.Request) string {
    // Header en priorite (requetes JS)
    if token := r.Header.Get("X-CSRF-Token"); token != "" {
        return token
    }
    // Fallback form field (formulaires HTML)
    return r.FormValue("csrf_token")
}

Le header custom a un bonus : les requêtes cross-origin ne peuvent pas ajouter de headers custom sans preflight CORS. C'est une couche de protection supplémentaire, même si elle ne doit pas être la seule.

Rotation du token

Deux écoles : un token par session, ou un token par requête. Mon avis : un token par session suffit pour la grande majorité des cas. Le token-par-requête ajoute de la complexité (race conditions sur les requêtes parallèles, invalidation du back button) pour un gain de sécurité marginal.

La seule raison de rotater le token : après un changement de privilège (login, escalade de rôle). Et à ce moment-là, tu régénères toute la session de toute façon.

Conclusion

Le double-submit cookie est un bon default pour les applications standard. Mais quand tu gères de l'authentification, des opérations financières, ou des actions à haut risque, le synchronizer token server-side est le bon choix. Le token n'est jamais dans un cookie, la comparaison est en constant-time, et le wire-order du middleware force une discipline sur la chaîne de traitement.

Jusqu'ici, on a couvert le login, le brute force, et le CSRF. Le tout sur un service qui tourne en mTLS. Justement, le mTLS a ses propres subtilités : comment servir trois audiences différentes sur un seul port TLS avec des niveaux de client auth distincts ? C'est le sujet du prochain article.

Commentaires (0)