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.exampleversapp.exampleporte encore le cookie de session deapp.example, sauf si l'attributSameSitedu cookie l'en empêche. - Attribut SameSite du cookie
Strictretient le cookie sur toute requête intersite,Laxne l'envoie que lors des navigations GET de premier niveau etNonel'envoie toujours (et exigeSecure). Chromium traite les cookies sans attribut commeLax; 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
CSRFProtectde Flask-WTF l'émet viacsrf_token()et vérifie le champcsrf_tokenou l'en-têteX-CSRFToken. - Fetch Metadata (Sec-Fetch-Site)
- Les navigateurs modernes envoient
Sec-Fetch-Site: cross-site,same-site,same-originounonesur chaque requête, et les pages ne peuvent pas le falsifier. Refuser les méthodes non sûres qui ne sont passame-originbloque 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, etSameSite=Laxenvoie encore le cookie lors des navigations GET de premier niveau. Lirerequest.valuesaccepte la query string en plus du corps du formulaire.
Déroulement de l'Attaque Étape par Étape
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.
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.
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.
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é
# 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"}
# 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
- Ne modifiez l'état qu'en POST, PUT, PATCH ou DELETE, et lisez les données du formulaire dans le corps (
request.form), jamais dansrequest.valuesou la query string. - Activez le middleware CSRF du framework pour toute l'application (
CSRFProtect,CsrfViewMiddlewarede Django,csrf()de Spring Security) et incluez le jeton dans chaque formulaire et requête AJAX. - Configurez les cookies de session en
Secure,HttpOnlyetSameSite=LaxouStrict; réservezSameSite=Noneaux cookies qui doivent fonctionner dans des iframes intersites. - Refusez les requêtes non sûres dont l'en-tête
Sec-Fetch-Sitevautcross-siteousame-site. - Ajoutez un test CI qui envoie à chaque route modifiant l'état une requête sans jeton et attend 400 ou 403.