Cross-Site Request Forgery (CSRF, CWE-352): Funktionsweise und Schutz
Eine Seite auf einer fremden Website kann den Browser eines angemeldeten Nutzers dazu bringen, einen datenändernden Request an Ihre App zu senden, und der Browser hängt das Sitzungscookie automatisch an, sodass eine Route, die nur das Cookie prüft, die Aktion ausführt. Wie Synchronizer-Tokens, SameSite-Cookies und Sec-Fetch-Site-Prüfungen das verhindern, mit einem Beispiel in Flask und Flask-WTF.
Einfache Erklärung (ELI5)
Ihr Büro hat eine Poststelle, die jeden unterschriebenen Auftragszettel in Ihrem Postausgangskorb erledigt, und sie vertraut den Zetteln, weil sie in Ihrem Korb liegen, an den nur Sie herankommen. Ein Besucher bittet Sie, beim Hinausgehen einen verschlossenen Umschlag in Ihren Korb zu legen. Darin steht auf einem Zettel: „Ändern Sie die Lieferadresse für alle meine Pakete.“ Die Poststelle findet ihn in Ihrem Korb, nimmt an, dass er von Ihnen stammt, und erledigt ihn. CSRF funktioniert genauso: Der Browser hängt Ihr Sitzungscookie an jeden Request an Ihre Bank, auch an einen, den eine andere Website ausgelöst hat. Die Lösung ist ein geheimer Stempel, den nur Seiten aus Ihrem eigenen Büro auf einen Zettel setzen können, damit die Poststelle Zettel ohne ihn ablehnt.
Kernkonzepte & Begriffe
- Umgebungs-Zugangsdaten
- Browser hängen Cookies und HTTP-Authentifizierung an Requests für eine Website, egal welche Seite sie ausgelöst hat. Ein Request von
evil.exampleanapp.exampleträgt weiterhin das Sitzungscookie vonapp.example, sofern dasSameSite-Attribut des Cookies das nicht verhindert. - SameSite-Attribut des Cookies
Stricthält das Cookie bei jedem websiteübergreifenden Request zurück,Laxsendet es nur bei GET-Navigationen auf oberster Ebene, undNonesendet es immer (und verlangtSecure). Chromium behandelt Cookies ohne Attribut alsLax, Firefox und Safari nicht, und „same-site“ umfasst auch benachbarte Subdomains.- Synchronizer-Token
- Ein an die Sitzung gebundener Zufallswert, den der Server in jedes Formular oder jede Seite schreibt und bei jedem unsicheren Request verlangt. Eine fremde Website kann ihn nicht lesen und daher keinen gültigen Request fälschen.
CSRFProtectaus Flask-WTF gibt ihn übercsrf_token()aus und prüft das Feldcsrf_tokenoder den HeaderX-CSRFToken. - Fetch Metadata (Sec-Fetch-Site)
- Moderne Browser senden bei jedem Request
Sec-Fetch-Site: cross-site,same-site,same-originodernone, und Seiten können den Header nicht fälschen. Wer unsichere Methoden ablehnt, die nichtsame-originsind, blockiert CSRF mit einer einzigen serverseitigen Prüfung. - Zustandsänderung per GET
- Eine Route, die Daten per GET ändert, lässt sich mit einem
<img>-Tag oder einem Link auslösen, undSameSite=Laxsendet das Cookie bei GET-Navigationen auf oberster Ebene weiterhin mit.request.valuesliest neben dem Formular-Body auch den Query-String.
Schritt-für-Schritt Angriffsablauf
Das Opfer ist angemeldet
Es hat ein Sitzungscookie für app.example, gesendet mit SameSite=None oder ohne Attribut in einem Browser, der nicht standardmäßig Lax verwendet.
Es öffnet eine Seite unter Kontrolle des Angreifers
Sie enthält ein verstecktes Formular, das an https://app.example/account/email sendet und sich beim Laden selbst abschickt. Ein Formular mit dem harmlosen Testwert email=test@example.com zeigt den Fall.
Der Browser sendet den Request samt Cookie
Der Request trägt die Sitzung des Opfers, und nichts verrät dem Server, dass das Formular auf einer anderen Website lag.
Der Server ändert das Konto
Die E-Mail-Adresse gehört nun dem Angreifer, der danach das Passwort zurücksetzt und das Konto übernimmt. 2008 wiesen Zeller und Felten CSRF-Lücken dieser Art bei ING Direct, YouTube und der New York Times nach.
Quellcode: Verwundbar vs. Sicher
# app.py: das Sitzungscookie ist der einzige Beleg, dass der Nutzer es wollte
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 funktioniert auch, und request.values liest auch den 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: Token, SameSite-Cookie und Fetch Metadata zusammen
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",
)
# jeder POST braucht csrf_token; Formulare rendern {{ csrf_token() }}, fetch() sendet X-CSRFToken
csrf = CSRFProtect(app)
# von fremden Websites ausgelöste Schreibzugriffe werden vor der Route abgelehnt
@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)
# nur POST, Daten aus dem Formular-Body
@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"}
Checkliste für Engineering & Systemsicherheit
- Zustand nur per POST, PUT, PATCH oder DELETE ändern und Formulardaten aus dem Body lesen (
request.form), nie ausrequest.valuesoder dem Query-String. - Die CSRF-Middleware des Frameworks für die ganze App aktivieren (
CSRFProtect, DjangosCsrfViewMiddleware,csrf()in Spring Security) und das Token in jedes Formular und jeden AJAX-Request aufnehmen. - Sitzungscookies auf
Secure,HttpOnlyundSameSite=LaxoderStrictsetzen;SameSite=Nonenur für Cookies verwenden, die in websiteübergreifenden iframes funktionieren müssen. - Unsichere Requests ablehnen, deren Header
Sec-Fetch-Siteden Wertcross-siteodersame-sitehat. - Einen CI-Test ergänzen, der jede zustandsändernde Route ohne Token aufruft und 400 oder 403 erwartet.