Artikel

CSRF in einer Middleware, nicht in jeder Route

Ein Schutz, den man an fünfzig Stellen wiederholen muss, wird an einer Stelle vergessen. Wie ich die Herkunftsprüfung an eine zentrale Stelle gezogen habe und was dabei zu beachten war.

BlackZackBlackzack

909 Wörter · 5 Min. Lesezeit

  • sicherheit
  • web
  • nextjs

Eine Weboberfläche mit Cookie-Anmeldung hat ein strukturelles Problem: Der Browser schickt das Cookie bei jeder Anfrage an meine Domain mit — auch dann, wenn die Anfrage von einer ganz anderen Seite ausgelöst wurde.

Das ist die Grundlage für Cross-Site Request Forgery. Eine fremde Seite bringt den Browser eines angemeldeten Nutzers dazu, eine verändernde Anfrage an meine Anwendung zu stellen. Der Nutzer merkt nichts, mein Server sieht eine gültige Sitzung.

Warum ich es zentral gelöst habe

Meine erste Fassung hatte die Prüfung in einzelnen Routen. Das funktioniert und hat eine Eigenschaft, die es unbrauchbar macht: Man kann es vergessen.

Genau das war mir vorher schon einmal passiert — eine einzelne Route ohne Rechteprüfung, während alle anderen sie hatten. Nicht aus Nachlässigkeit, sondern weil sie später dazukam und niemand einen Grund hatte, alle anderen daneben zu legen.

Sicherheitsprüfungen, die man an vielen Stellen wiederholen muss, sind statistisch dazu verurteilt, an einer Stelle zu fehlen. Also gehören sie an eine Stelle, die alles passiert.

Die Prüfung selbst

Der Kern ist ein Abgleich der Herkunft. Bei verändernden Anfragen — alles außer Lesezugriffen — muss die anfragende Seite die eigene sein.

export function middleware(anfrage) {
  const methode = anfrage.method;
  if (methode === "GET" || methode === "HEAD" || methode === "OPTIONS") {
    return NextResponse.next();
  }
 
  const herkunft = anfrage.headers.get("origin");
  const ziel = anfrage.headers.get("host");
 
  if (!herkunft) {
    return neueAntwort(403, "ORIGIN_MISSING");
  }
  if (new URL(herkunft).host !== ziel) {
    return neueAntwort(403, "ORIGIN_MISMATCH");
  }
  return NextResponse.next();
}

Drei Details, die ich mir erarbeiten musste:

Nur verändernde Methoden prüfen. Ein Lesezugriff soll auch von fremden Seiten aus funktionieren dürfen — sonst brechen Verlinkungen und Vorschauen.

Fehlende Herkunft ablehnen, nicht durchlassen. Moderne Browser setzen den Herkunftskopf bei verändernden Anfragen. Fehlt er, ist das ein Grund zur Ablehnung, nicht zur Nachsicht — sonst genügt es, ihn wegzulassen, um die Prüfung zu umgehen.

Gegen den Host-Kopf vergleichen, nicht gegen die abgeleitete Adresse. Das war mein Fehler in der ersten Fassung: Ich habe die Herkunft mit der Adresse verglichen, die das Framework aus der Anfrage abgeleitet hatte. Hinter einem Proxy ist das nicht dasselbe, und je nach Konstellation vergleicht man dann zwei Werte, die beide vom Aufrufer stammen.

Infobox

Dazu gehört die passende Cookie-Einstellung. SameSite=Lax sorgt dafür, dass Cookies bei verändernden Anfragen von fremden Seiten gar nicht erst mitgeschickt werden. Das ist der erste Schutzwall, die Herkunftsprüfung der zweite. Beide zusammen, weil SameSite von der Browserfassung abhängt und die Prüfung nicht.

Was dabei schiefgehen kann

Legitime Aufrufe von außen. Wenn eine externe Anwendung meine Schnittstelle aufrufen soll, scheitert sie an der Herkunftsprüfung. Die Lösung ist nicht, die Prüfung zu lockern, sondern die Trennung: Cookie-Anmeldung für die Weboberfläche, Token-Anmeldung für alles andere. Wo kein Cookie mitgeschickt wird, gibt es das Problem nicht — und die Prüfung greift dort nicht.

Zeitgesteuerte Aufrufe. Ein Auslöser, der nachts etwas anstößt, hat keine Herkunft. Auch hier: eigener Weg mit eigenem Geheimnis statt Ausnahme in der zentralen Prüfung.

Fehlermeldungen, die niemand versteht. Ein 403 ohne Erklärung ist im Support unbrauchbar. Meine Antwort enthält deshalb einen Code, der sagt, welche der beiden Prüfungen gegriffen hat.

Was noch dazugehört

Eine Herkunftsprüfung ist eine Schicht, nicht die ganze Absicherung. Bei mir kommt zweierlei dazu.

Cookies mit den richtigen Attributen. Die Einstellung, die verhindert, dass Cookies bei Anfragen von fremden Seiten überhaupt mitgeschickt werden. Damit greift der Schutz schon im Browser, bevor die Anfrage bei mir ankommt.

Kurze Sitzungsdauer für alles Sensible. Eine Sitzung von dreißig Tagen ist bequem. Für Bereiche mit weitreichenden Rechten ist eine erneute Bestätigung nach kurzer Zeit angemessen.

Und eine Regel, die ich mir für alle verändernden Aufrufe angewöhnt habe: Kein Zustandswechsel über eine Lesemethode. Ein Link, der beim Anklicken etwas löscht, ist ein Fehler — nicht nur wegen dieser Angriffsart, sondern weil jeder Vorschau-Mechanismus und jeder Vorablade-Vorgang ihn auslösen kann.

Warum ich zentrale Prüfungen inzwischen bevorzuge

Diese Umstellung war der Auslöser für ein Muster, das ich seitdem überall anwende: Sicherheitsprüfungen gehören an eine Stelle, die man nicht umgehen kann, indem man etwas Neues hinzufügt.

Es gibt einen einfachen Test dafür. Ich stelle mir vor, ich schreibe in sechs Monaten unter Zeitdruck eine neue Route. Wird sie automatisch geschützt sein?

  • Bei einer zentralen Schicht: ja.
  • Bei einem Aufruf, den man in jeder Route wiederholen muss: nein, statistisch irgendwann nicht.

Der Unterschied ist nicht Sorgfalt, sondern Struktur. Und Struktur ist die einzige Form von Sorgfalt, die auch dann noch wirkt, wenn man müde ist oder es eilig hat.

Wie ich geprüft habe, dass es wirkt

Eine Schutzmaßnahme, die man nicht getestet hat, ist eine Vermutung. Bei dieser hier ist der Test einfach.

Eine verändernde Anfrage mit fremder Herkunft schicken und prüfen, dass sie abgelehnt wird:

curl -X POST https://beispiel.de/api/einstellungen \
  -H "Origin: https://fremde-seite.example" \
  -H "Cookie: <gültige Sitzung>" \
  -d '{"prefix":"?"}'
# erwartet: 403

Dieselbe Anfrage ohne Herkunftsangabe — ebenfalls abgelehnt.

Dieselbe Anfrage mit richtiger Herkunft — erlaubt.

Eine Leseanfrage mit fremder Herkunft — erlaubt, weil sie nichts verändert.

Vier Aufrufe, zwei Minuten. Und sie beantworten die Frage vollständig, statt sie im Code zu vermuten.

Diesen Test habe ich mir als kleines Skript abgelegt und lasse ihn nach jeder Änderung an der Zwischenschicht laufen. Der Grund ist derselbe wie überall: Eine Schutzmaßnahme, die einmal funktioniert hat, funktioniert nicht automatisch weiter — besonders dann nicht, wenn jemand später eine Ausnahme einbaut.

Die allgemeine Lehre

Das Prinzip lässt sich von CSRF loslösen: Sicherheitsmaßnahmen gehören an eine Stelle, die man nicht umgehen kann, indem man etwas Neues hinzufügt.

Eine Prüfung in einer zentralen Schicht gilt automatisch für die Route, die ich nächstes Jahr schreibe. Eine Prüfung, die ich in jede Route kopiere, gilt für die Routen, an die ich gedacht habe.

Dasselbe Muster steckt bei mir inzwischen an mehreren Stellen: die Autorisierung als Zwischenschicht vor einer ganzen Routengruppe, die Maskierung von Geheimnissen in der Protokollausgabe, die Standardkopfzeilen im Reverse Proxy. Überall dieselbe Idee — der sichere Zustand ist die Voreinstellung, und Abweichungen sind ausdrücklich.