Sitzungen, Cookies und CSRF
Kurz gesagt: CSRF ist ein Angriff, bei dem eine andere Website eine Anfrage an deine schickt und der Browser das Sitzungs-Cookie anhängt; SameSite-Cookies und ein CSRF-Token halten ihn auf.
Problem
Die Adresse, die Maria nicht geändert hat
Maria, eine Käuferin, schreibt dem Support des Shops: Ihr Paket ging an „Schwindelgasse 13“, aber sie wohnt in der „Bahnhofstraße 15“. Ihr Passwort ist stark, und niemand kennt es. Im Log des Shops steht eine Anfrage POST /account/address um 21:14, mit ihrer Sitzung, aus ihrem Browser.
Maria schwört, dass sie um 21:14 nicht im Shop war. Sie war auf einer Seite „Gewinne einen Preis!“ in einem anderen Tab. Niemand hat ihr Passwort oder ihr Cookie gestohlen. Wie hat dann ihr eigener Browser ihre Adresse geändert?
Die Idee
Wie der Shop sich merkt, dass du angemeldet bist
HTTP hat kein Gedächtnis: Jede Anfrage steht für sich (du hast es in der Lektion über das Netz gesehen). Also legt der Shop nach der Anmeldung eine Sitzung an: Er schreibt in eine Tabelle „diese Id ist Maria“ und schickt die Id dem Browser in einem Cookie, mit dem Header Set-Cookie. Von da an schickt der Browser das Cookie bei jeder Anfrage an den Shop von selbst mit, und der Shop findet Maria über die Id.
Die Id ist wie eine Garderobenmarke: Wer sie hält, bekommt den Mantel. Also muss sie sein:
- zufällig: 32 Bytes aus einem kryptografischen Generator, kein Zähler und kein gewöhnliches
Random. Sonst lässt sich die nächste Id erraten; - HttpOnly: Die Skripte der Seite sehen sie nicht. Auch ein fremdes, per XSS eingeschleustes Skript kann sie nicht lesen;
- Secure: Sie geht nur über HTTPS, nie über einen offenen Kanal;
- SameSite: wenn sie von der Seite einer anderen Website zum Shop geht. Das ist die zweite Hälfte der Lektion.
Das Passwort wird einmal geprüft, bei der Anmeldung (wie in der Lektion über Passwörter), und danach funktioniert nur noch die Id. Also gibt der Shop bei jeder Anmeldung eine neue Id und vergisst die alte. Warum, steht unter „Genauer, für Neugierige“.
Sieh es dir an
Die Sitzung im Code
Das Cookie und die Anfrage sind Objekte. Die Website, von der die Anfrage kommt, ist ein Feld: So siehst du in einem Prozess und ohne Netz alles, was Browser und Server tun.
class Cookie { constructor(name, value, sameSite) { this.name = name; this.value = value; this.sameSite = sameSite; }
header() { return `${this.name}=${this.value}; HttpOnly; Secure; SameSite=${this.sameSite}; Path=/`; }}
// request: { site, method, path, cookies, form }// site: die Website der Seite, von der die Anfrage kommtclass Shop { sessions = new Map(); // Id → Käufer addresses = new Map();
constructor(sameSite) { this.sameSite = sameSite; }
login(user, oldId) { // bei jeder Anmeldung eine neue Id; die alte gilt nicht mehr this.sessions.delete(oldId); const id = randomBytes(32).toString("base64url"); this.sessions.set(id, user); return new Cookie("session", id, this.sameSite); }
who(request) { return this.sessions.get(request.cookies.session); }}randomBytes(32) aus node:crypto liefert kryptografisch zufällige Bytes, und toString("base64url") macht daraus Text, der in ein Cookie passt: 43 Zeichen.Maria meldet sich zweimal an, dann probiert der Browser die neue Id, die alte und eine ausgedachte:
const shop = new Shop("Lax");const browser = new Browser();const first = shop.login("maria");const second = shop.login("maria", first.value); // meldet sich erneut anconsole.log(`id: ${second.value.length} Zeichen`);console.log(`die beiden Anmeldungen ergeben verschiedene Ids: ${first.value === second.value ? "nein" : "ja"}`);console.log("Set-Cookie: " + second.header().replace(second.value, "…"));browser.store(second);console.log(`die neue Id → ${visit(shop, browser)}`);browser.store(first);console.log(`die alte Id → ${visit(shop, browser)}`);browser.store(new Cookie("session", "123456", "Lax"));console.log(`eine ausgedachte Id → ${visit(shop, browser)}`);id: 43 Zeichendie beiden Anmeldungen ergeben verschiedene Ids: jaSet-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/die neue Id → mariadie alte Id → niemandeine ausgedachte Id → niemandDie Ids sind bei jedem Lauf anders, deshalb gibt das Programm nur die Länge und „ja/nein“ aus. Nach der zweiten Anmeldung führt die alte Id zu niemandem.
Die Idee
Der Browser schickt das Cookie von selbst
Das hatte die Seite „Gewinne einen Preis!“:
<form action="https://shop.example/account/address" method="post"> <input type="hidden" name="address" value="Schwindelgasse 13"></form><script>document.forms[0].submit()</script>Ein verstecktes Formular an den Shop und eine Zeile, die es sofort abschickt. Der Angreifer kennt Marias Cookie nicht und braucht es nicht. Der Browser sieht eine Anfrage an shop.example und hängt das Cookie des Shops von selbst an. Der Handler findet Maria dadurch und ändert die Adresse.
Das heißt CSRF (Cross-Site Request Forgery). Das Cookie beweist, wer im Browser sitzt, aber nicht, welche Seite die Anfrage gestartet hat. Ein Handler, der nur dem Cookie vertraut, vertraut jeder Seite im Internet, die Maria öffnet.
Sieh es dir an
Das andere Formular und die zwei Schutzmaßnahmen
// POST /account/addresschangeAddress(request) { const user = this.who(request); if (user === undefined) return 401; // keine Sitzung this.addresses.set(user, request.form.address); return 200;}login(user, oldId) { // bei jeder Anmeldung eine neue Id; die alte gilt nicht mehr this.sessions.delete(oldId); const id = randomBytes(32).toString("base64url"); this.sessions.set(id, user); return new Cookie("session", id, this.sameSite);}// POST /account/addresschangeAddress(request) { const user = this.who(request); if (user === undefined) return 401; // keine Sitzung this.addresses.set(user, request.form.address); return 200;}function attach(cookie, site, method) { // dieselbe Website: Das Cookie geht immer mit if (site === "shop.example") return true; switch (cookie.sameSite) { case "None": return true; case "Lax": return method === "GET"; default: return false; // Strict }}changeAddress(request) { const s = this.session(request); if (s === undefined) return 401; // keine Sitzung const sent = request.form.csrf ?? ""; if (!same(sent, s.csrf)) return 403; this.addresses.set(s.user, request.form.address); return 200;}Schritt für Schritt, in Worten
- Maria meldet sich bei shop.example an. Das Passwort stimmt, also öffnet der Shop eine Sitzung für sie.
- Die Sitzungs-Id besteht aus 32 Zufallsbytes. Der Shop merkt sich: Diese Id ist Maria.
- Die Id geht im Sitzungs-Cookie zurück. Ab jetzt schickt der Browser sie bei jeder Anfrage an den Shop mit.
- In einem anderen Tab öffnet Maria free-gifts.example. Seine Seite hat ein verstecktes Formular, das an den Shop postet und sich selbst abschickt.
- Das Cookie ist SameSite=None, also hängt der Browser es an die Anfrage an. Der Handler findet Maria dadurch.
- Er vertraut dem Cookie und ändert die Adresse: 200, Schwindelgasse 13. Maria hat im Shop nichts angeklickt.
- Lösung 1: SameSite=Lax. Bei einem POST von einer anderen Website lässt der Browser das Cookie zu Hause. Seiten des Shops bekommen es weiter.
- Dasselbe Formular, aber ohne das Cookie sieht der Handler keine Sitzung: 401. Die Adresse bleibt Bahnhofstraße 15.
- Lösung 2: ein CSRF-Token. Die Sitzung hält ein zufälliges Token, und die Seite des Shops legt es in ein verstecktes Feld ihres Formulars.
- Auch mit SameSite=None kann die andere Seite dieses Token nicht lesen. Ihr Formular kommt mit dem Cookie, aber ohne das Token an: 403.
- Das Formular des Shops schickt das Token mit, und es passt: 200, Gartenweg 7.
- Das Cookie sagt, wer du bist. Nur das Token sagt, dass die Anfrage von einer Seite des Shops kommt.
Der Browser entscheidet, ob das Cookie mitgeht. Die Regel steht im Attribut SameSite: None (geht immer mit), Lax (von einer anderen Website nur mit GET) und Strict (von einer anderen Website nie).
class Browser { jar = new Map(); // die Cookies von shop.example
store(cookie) { this.jar.set(cookie.name, cookie); }
// eine Seite irgendeiner Website schickt eine Anfrage an den Shop send(site, method, path, form = {}) { const cookies = {}; for (const c of this.jar.values()) { if (attach(c, site, method)) cookies[c.name] = c.value; } return { site, method, path, cookies, form }; }}
function attach(cookie, site, method) { // dieselbe Website: Das Cookie geht immer mit if (site === "shop.example") return true; switch (cookie.sameSite) { case "None": return true; case "Lax": return method === "GET"; default: return false; // Strict }}Der Handler aus dem Problem vertraut dem Cookie:
// POST /account/addresschangeAddress(request) { const user = this.who(request); if (user === undefined) return 401; // keine Sitzung this.addresses.set(user, request.form.address); return 200;}Maria, der Shop und die zwei Formulare, zuerst mit SameSite=None, dann mit Lax:
for (const sameSite of ["None", "Lax"]) { const shop = new Shop(sameSite); const browser = new Browser(); browser.store(shop.login("maria")); shop.addresses.set("maria", "Bahnhofstraße 15"); console.log(`SameSite=${sameSite}`); // eine Seite einer fremden Website schickt ihr Formular an den Shop show(shop, browser.send("free-gifts.example", "POST", "/account/address", { address: "Schwindelgasse 13" })); // das Formular des Shops selbst show(shop, browser.send("shop.example", "POST", "/account/address", { address: "Gartenweg 7" }));}SameSite=None free-gifts.example → 200, Adresse: Schwindelgasse 13 shop.example → 200, Adresse: Gartenweg 7SameSite=Lax free-gifts.example → 401, Adresse: Bahnhofstraße 15 shop.example → 200, Adresse: Gartenweg 7Mit Lax bekommt das andere Formular 401, und das Formular des Shops selbst geht durch: Es kommt von derselben Website. Die Anfrage ist ein einfaches Objekt { site, method, path, cookies, form }, keine Klasse: In JavaScript ist das der kürzeste Weg.
Probier es aus
Sage die Ausgabe voraus
Wann geht das Cookie mit? Was gibt das aus?
const lax = new Cookie("session", "…", "Lax");const strict = new Cookie("session", "…", "Strict");console.log(attach(lax, "free-gifts.example", "GET"));console.log(attach(lax, "free-gifts.example", "POST"));console.log(attach(strict, "free-gifts.example", "GET"));console.log(attach(strict, "shop.example", "POST"));Lax lässt das Cookie von einer anderen Website nur mit GET mitgehen, also ist das erste ja und das zweite nein. Strict lässt es von einer anderen Website nie mitgehen, aber auf der eigenen Website geht es immer mit, auch mit POST.
Die Idee
Das Token, das die andere Seite nicht lesen kann
SameSite ist eine Schutzmaßnahme des Browsers. Sie ist gut, aber allein nicht genug: Alte Browser, Subdomains derselben Website und Handler, die bei GET etwas ändern, umgehen sie. Also hat der Shop auch eine eigene Schutzmaßnahme: ein CSRF-Token.
Bei der Anmeldung bekommt die Sitzung einen weiteren Zufallswert, das Token. Die Seite des Shops legt es in ein verstecktes Feld ihres Formulars. Der Handler akzeptiert die Anfrage nur, wenn das Token im Formular zu dem in der Sitzung passt.
Die andere Seite kann eine Anfrage an den Shop schicken, aber die Antwort nicht lesen: Der Browser lässt es nicht zu (das ist die Same-Origin-Policy). Sie kennt das Token also nicht und kann es nicht ins Formular legen.
login(user, oldId) { this.sessions.delete(oldId); const id = randomBytes(32).toString("base64url"); // und ein CSRF-Token für die Formulare const csrf = randomBytes(32).toString("base64url"); this.sessions.set(id, { user, csrf }); return new Cookie("session", id, this.sameSite);}
session(request) { return this.sessions.get(request.cookies.session);}
// die Seite des Shops legt das Token in ein verstecktes FeldformToken(request) { return this.session(request).csrf;}changeAddress(request) { const s = this.session(request); if (s === undefined) return 401; // keine Sitzung const sent = request.form.csrf ?? ""; if (!same(sent, s.csrf)) return 403; this.addresses.set(s.user, request.form.address); return 200;}function same(a, b) { // vergleicht alle Bytes, ohne beim ersten Unterschied anzuhalten const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y);}Auch mit SameSite=None wird das andere Formular aufgehalten:
const shop = new Shop("None");const browser = new Browser();browser.store(shop.login("maria"));shop.addresses.set("maria", "Bahnhofstraße 15");console.log("SameSite=None + CSRF");show(shop, browser.send("free-gifts.example", "POST", "/account/address", { address: "Schwindelgasse 13" }));// die fremde Website kennt das Token nicht und rätshow(shop, browser.send("free-gifts.example", "POST", "/account/address", { address: "Schwindelgasse 13", csrf: "123456" }));// die Seite des Shops legt das Token in ein verstecktes Feldconst token = shop.formToken(browser.send("shop.example", "GET", "/account"));show(shop, browser.send("shop.example", "POST", "/account/address", { address: "Gartenweg 7", csrf: token }));SameSite=None + CSRF free-gifts.example → 403, Adresse: Bahnhofstraße 15 free-gifts.example → 403, Adresse: Bahnhofstraße 15 shop.example → 200, Adresse: Gartenweg 7Probier es aus
Ergänze die Lücke
Welche Funktion vergleicht die Tokens, ohne beim ersten Unterschied anzuhalten?
return x.length === y.length && (x, y);crypto.timingSafeEqual sieht alle Bytes an. Sie wirft bei unterschiedlicher Länge einen Fehler, deshalb werden die Längen vorher verglichen.
Probier es aus
Finde den Fehler
Ein Kollege fügt ein CSRF-Token hinzu, und die Tests laufen durch: Ein falsches Token ergibt 403. Eine Woche später werden Adressen wieder von einer anderen Website aus geändert. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Das Token wird nur geprüft, wenn es da ist. Das andere Formular schickt einfach kein Feld csrf mit und umgeht die Prüfung. Ein fehlendes Token muss eine Ablehnung sein, genau wie ein falsches: request.form.csrf ?? "" und dann der Vergleich, ohne Bedingung. Eine Sicherheitsprüfung lehnt im Zweifel ab; sie lässt nicht durch.
Genauer, für Neugierige
Session Fixation. Der Angreifer holt sich vom Shop eine Id (zum Beispiel als Gast) und schiebt sie Maria unter: in einem Link, in einem Cookie einer Subdomain. Behält der Shop nach ihrer Anmeldung dieselbe Id, ist der Angreifer als Maria angemeldet. Deshalb wird bei der Anmeldung die alte Id gelöscht und eine neue vergeben, wie es login in der Lektion macht.
Abmelden. Beim Abmelden wird die Sitzung auf dem Server gelöscht, nicht nur das Cookie im Browser. Sonst funktioniert eine gestohlene Id nach dem Abmelden weiter.
Lax, genauer. Lax lässt das Cookie von einer anderen Website nur bei einer Navigation der ganzen Seite mit einer „sicheren“ Methode (GET, HEAD) mitgehen: wenn Maria auf einen Link zum Shop klickt. Versteckte Anfragen mit fetch oder in einem <iframe> bekommen es nicht. Deshalb darf GET nie etwas ändern: Sonst reicht ein Link für den Angriff. Hat das Cookie kein SameSite, behandeln Chrome und Edge es als Lax, Firefox und Safari aber nicht immer. Also wird das Attribut ausdrücklich geschrieben.
Origin prüfen. Der Browser schickt die Header Origin und Sec-Fetch-Site, die sagen, woher die Anfrage kommt, und eine Seite einer anderen Website kann sie nicht fälschen. Sie zu prüfen ist eine gute dritte Schicht.
In einem echten Projekt macht das Framework das Token: Spring Security (standardmäßig an), ASP.NET Core antiforgery, Django (CsrfViewMiddleware), und in Express eine Bibliothek wie csrf-csrf. Auch Sitzungen gibt es fertig: HttpSession in Java, ISession in ASP.NET Core, die Sitzungen von Django und Flask, express-session. Das Präfix __Host- im Namen des Cookies (__Host-session) lässt den Browser Secure, Path=/ und kein Domain verlangen.
HTTPS (die Zertifikate und die Einrichtung des Servers) gehört zur Infrastruktur und wird im DevOps-Kurs ZeroToOps behandelt.
Übung
Eine Bestellung stornieren
Löse die Aufgabe in deinem eigenen Editor.
Der Shop hat auch ein Formular „Bestellung stornieren“: POST /orders/cancel mit einem Feld order. Schreibe den Handler cancelOrder: Ohne Sitzung gibt er 401 zurück, ohne korrektes CSRF-Token 403, sonst markiert er die Bestellung als storniert und gibt 200 zurück. Probiere ihn mit drei Anfragen aus, mit dem Cookie SameSite=None: von der anderen Website mit Marias Cookie, aber ohne Token; von einem Browser, der nicht angemeldet ist; von der Seite des Shops mit dem Token.
fremde Website: 403, Bestellung 1042: aktivnicht angemeldet: 401, Bestellung 1042: aktivder Shop: 200, Bestellung 1042: storniertWeitere Aufgaben
- Ein neues Token. Gib nach jeder erfolgreichen Adressänderung ein neues CSRF-Token aus. Was passiert, wenn Maria das Formular in zwei Tabs offen hat?
- Origin. Füge der Anfrage ein Feld
originhinzu und lehne jede Anfrage ab, die etwas ändert und nicht vonshop.examplekommt.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Daten, switch und Destrukturierung · Netzwerkprogrammierung · Nicht vertrauenswürdige Eingaben · Passwörter
- Geht weiter in: JWT und seine Fallen · Skalierung
- Aufgaben zum Thema: Sicherheit für Entwickler