● CWE-352 · OWASP A01:2021

Cross-Site Request Forgery (CSRF, CWE-352) : fonctionnement et prévention

Une page d'un autre site peut faire envoyer par le navigateur d'un utilisateur connecté une requête qui modifie des données dans votre application, et le navigateur joint automatiquement le cookie de session : une route qui ne vérifie que le cookie exécute donc l'action. Comment les jetons synchroniseurs, les cookies SameSite et les contrôles Sec-Fetch-Site l'empêchent, avec un exemple Flask et Flask-WTF.

Explication en Langage Simple (ELI5)

Votre bureau a un service courrier qui exécute toute demande signée déposée dans votre bac de départ, et il leur fait confiance parce qu'elles sont dans votre bac, que vous seul atteignez. Un visiteur vous demande de déposer une enveloppe fermée dans votre bac en partant. Dedans, une demande dit « changez l'adresse de livraison de tous mes colis ». Le service courrier la trouve dans votre bac, suppose que vous l'avez écrite et l'exécute. Le CSRF fonctionne de la même façon : le navigateur met votre cookie de session sur chaque requête vers votre banque, y compris celle qu'un autre site a déclenchée. La correction est un tampon secret que seules les pages de votre propre bureau peuvent apposer, pour que le service refuse les demandes qui ne l'ont pas.

Concepts Clés et Termes

Identifiants ambiants
Les navigateurs joignent cookies et authentification HTTP aux requêtes vers un site quelle que soit la page qui les a déclenchées. Une requête de evil.example vers app.example porte encore le cookie de session de app.example, sauf si l'attribut SameSite du cookie l'en empêche.
Attribut SameSite du cookie
Strict retient le cookie sur toute requête intersite, Lax ne l'envoie que lors des navigations GET de premier niveau et None l'envoie toujours (et exige Secure). Chromium traite les cookies sans attribut comme Lax ; Firefox et Safari non, et « même site » inclut aussi les sous-domaines voisins.
Jeton synchroniseur
Une valeur aléatoire liée à la session que le serveur place dans chaque formulaire ou page et exige sur chaque requête non sûre. Un autre site ne peut pas la lire, donc ne peut pas forger de requête valide. Le CSRFProtect de Flask-WTF l'émet via csrf_token() et vérifie le champ csrf_token ou l'en-tête X-CSRFToken.
Fetch Metadata (Sec-Fetch-Site)
Les navigateurs modernes envoient Sec-Fetch-Site: cross-site, same-site, same-origin ou none sur chaque requête, et les pages ne peuvent pas le falsifier. Refuser les méthodes non sûres qui ne sont pas same-origin bloque le CSRF en un seul contrôle côté serveur.
Changement d'état en GET
Une route qui modifie des données en GET peut être déclenchée par une balise <img> ou un lien, et SameSite=Lax envoie encore le cookie lors des navigations GET de premier niveau. Lire request.values accepte la query string en plus du corps du formulaire.

Déroulement de l'Attaque Étape par Étape

Étape 1

La victime est connectée

Elle possède un cookie de session pour app.example, envoyé avec SameSite=None ou sans attribut dans un navigateur qui n'applique pas Lax par défaut.

Étape 2

Elle ouvre une page contrôlée par l'attaquant

Elle contient un formulaire caché qui poste vers https://app.example/account/email et se soumet au chargement. Un formulaire qui poste la valeur de test inoffensive email=test@example.com illustre le cas.

Étape 3

Le navigateur envoie la requête avec le cookie

La requête porte la session de la victime, et rien n'indique au serveur que le formulaire se trouvait sur un autre site.

Étape 4

Le serveur modifie le compte

L'adresse e-mail devient celle de l'attaquant, qui réinitialise ensuite le mot de passe et prend le compte. En 2008, Zeller et Felten ont montré des failles CSRF de ce type chez ING Direct, YouTube et The New York Times.

Code Source : Vulnérable vs Sécurisé

IMPLÉMENTATION VULNÉRABLE
# app.py : le cookie de session est la seule preuve que l'utilisateur l'a voulu
import os

from flask import Flask, abort, request, session

from .db import db

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="None",
)


# GET marche aussi, et request.values lit aussi la query string
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
    user_id = session.get("user_id")
    if user_id is None:
        abort(401)
    db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
    return {"status": "ok"}
PATCH SÉCURISÉ ET ROBUSTE
# app.py : jeton, cookie SameSite et Fetch Metadata ensemble
import os

from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect

from .db import db

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
)
# chaque POST exige csrf_token ; les formulaires affichent {{ csrf_token() }}, fetch() envoie X-CSRFToken
csrf = CSRFProtect(app)


# les écritures déclenchées par un autre site sont refusées avant la route
@app.before_request
def reject_cross_site_writes():
    if request.method not in ("GET", "HEAD", "OPTIONS"):
        if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
            abort(403)


# POST uniquement, données issues du corps du formulaire
@app.post("/account/email")
def change_email():
    user_id = session.get("user_id")
    if user_id is None:
        abort(401)
    db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
    return {"status": "ok"}

Liste de Contrôle de Sécurité pour l'Ingénierie

Sources

← Parcourir tout l'annuaire sécurité Tous les guides de vulnérabilités →