Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)

Die Rotation war wirkungslos: Der alte Refresh-Token blieb bis exp gueltig, ein
gestohlener Token also bis zu 7 Tage parallel zum legitimen nutzbar - der
Pentester trug mit einem Token 90 Parallel-Requests.

Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine jti und
gehoert zu einer Sitzungsfamilie (neue Tabelle RefreshTokenRecord). Beim
Einloesen wird die jti verbraucht; taucht sie erneut auf, wird die gesamte
Familie widerrufen und der Vorfall als SUSPICIOUS/CRITICAL gemeldet. Der Token
selbst wird nicht gespeichert - die Signatur authentifiziert ihn bereits, und
ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.

Kulanzfenster fuer parallele Tabs: 15 s und hoechstens 3 Wiederverwendungen.
Ohne Toleranz wuerde der zweite legitime Tab die Sitzung sprengen; die enge
Grenze laesst einen Missbrauchs-Burst trotzdem auflaufen.

Das Einloesen ist atomar (bedingtes UPDATE statt Lesen-dann-Schreiben) -
derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90
gleichzeitige Requests ausnahmslos durch, weil alle den Token als unbenutzt
lasen.

Verifiziert: 90 parallele Requests -> nur 4 erfolgreich (1 + Kulanz 3), 27 als
Replay erkannt, alle Folge-Tokens tot; 2 parallele Tabs weiterhin erfolgreich;
gestohlener Token spaeter erneut abgewiesen; Logout widerruft die Familie;
ueber HTTP kommt SUSPICIOUS/CRITICAL an. tsc + vite build gruen.

Deploy-Hinweis: Refresh-Tokens ohne jti (Bestand vor dem Deploy) werden
fail-closed abgewiesen - alle angemeldeten Nutzer muessen sich einmalig neu
anmelden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:02:53 +02:00
co-authored by Claude Opus 5
parent 044a12f73e
commit 9fcab6f17e
5 changed files with 287 additions and 15 deletions
+32
View File
@@ -97,6 +97,38 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)** (2026-08-18)
- Die Rotation war bisher wirkungslos: Der alte Refresh-Token blieb bis `exp`
gueltig, ein gestohlener Token also **bis zu 7 Tage parallel** zum legitimen
nutzbar (der Pentester trug mit EINEM Token 90 Parallel-Requests).
- Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine `jti`
und gehoert zu einer Sitzungs-`familyId` (neue Tabelle
`RefreshTokenRecord`, Migration `20260818160000`). Beim Einloesen wird die
`jti` verbraucht; taucht sie erneut auf, wird die **gesamte Familie**
widerrufen Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt
es und der Vorfall wird als `SUSPICIOUS / CRITICAL` gemeldet.
- Der Token selbst wird NICHT gespeichert (die Signatur authentifiziert ihn
bereits); ein DB-Leck gibt damit keine nutzbaren Sitzungen preis.
- **Kulanzfenster** fuer parallele Tabs: 15 s UND hoechstens 3
Wiederverwendungen. Ohne Toleranz wuerde der zweite legitime Tab die
Sitzung sprengen; die enge Grenze laesst einen Missbrauchs-Burst trotzdem
auflaufen.
- **Einloesen ist atomar** (bedingtes UPDATE statt Lesen-dann-Schreiben)
derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90
gleichzeitige Requests ausnahmslos durch, weil alle den Token als „noch
unbenutzt“ lasen. Jetzt entscheidet die Datenbank, wer zuerst war.
- Verifiziert: Rotation vergibt neue `jti` in derselben Familie; 90 parallele
Requests → nur 4 erfolgreich (1 + Kulanz 3), 27 als Replay erkannt, Rest
widerrufen, alle daraus entstandenen Tokens tot; 2 parallele Tabs weiterhin
erfolgreich; gestohlener Token spaeter erneut → abgewiesen; Logout
widerruft die Familie; ueber HTTP kommt `SUSPICIOUS/CRITICAL` an.
Audit-Regression unveraendert (25/25, 0 Forks, alle V3). `tsc` +
`vite build` gruen.
- **Deploy-Hinweis:** Refresh-Tokens ohne `jti` (Bestand vor dem Deploy)
werden bewusst **fail-closed** abgewiesen (`REFRESH_LEGACY`, als LOW
gemeldet, kein Angriffsindiz). Alle angemeldeten Nutzer muessen sich nach
dem Deploy **einmalig neu anmelden**.
- [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18)
- Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
Kette selbsttragend wer die DB schreiben kann, konnte jede Zeile aendern