Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01)

Jede Kulanz-Wiedervorlage rotierte auf einen frischen Token mit eigenem,
zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token konnte damit aus
dem erkennbaren Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung
entkommen, die nie wieder mit der des Opfers kollidiert - dauerhaft unsichtbar,
kein einziges CRITICAL. Das hebelte die Kern-Garantie von R164-02 aus:
Diebstahl faellt bei der naechsten Nutzung auf.

Fix: Kulanz idempotent. Die jti des Nachfolgers wird bereits beim Einloesen im
selben bedingten UPDATE reserviert (replacedByJti). Eine Wiedervorlage im
Fenster gibt denselben bereits ausgestellten Nachfolger zurueck, statt neu zu
rotieren - ohne neuen Datensatz. Parallele Tabs laufen dadurch auf eine Linie
zusammen; wer den Token spaeter vorlegt, kollidiert zwangslaeufig und loest den
Familien-Widerruf aus. Fehlt der Nachfolger, wird bewusst nicht ersatzweise
rotiert (das waere wieder der Fork), sondern fail-closed als Replay gewertet.

Verifiziert (PoC nachgebaut): T0 legit -> TA, T0 replayt -> TC, TA.jti ==
TC.jti - kein Fork mehr. Ueber HTTP: zwei Tabs beide erfolgreich auf derselben
Linie; gestohlener Token nach Fensterablauf -> 401, Familie widerrufen,
Angreifer-Linie tot, SUSPICIOUS/CRITICAL gemeldet. Regression: 40 parallel ->
4 erfolgreich auf einer Linie, seriell 1-4 ok und 5. Replay, Token ohne jti
fail-closed, Logout widerruft. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 20:17:51 +02:00
co-authored by Claude Opus 5
parent 655d20db23
commit 791711ca58
2 changed files with 95 additions and 13 deletions
+67 -13
View File
@@ -45,9 +45,16 @@ const REFRESH_REUSE_MAX = 3;
/** Legt einen neuen Sitzungs-Refresh-Token an (neue Familie). */
export async function issueRefreshToken(
payload: JwtPayload,
opts: { userId?: number; customerId?: number; isCustomerPortal: boolean; familyId?: string },
opts: {
userId?: number;
customerId?: number;
isCustomerPortal: boolean;
familyId?: string;
/** Beim Rotieren bereits beim Einloesen reservierte jti des Nachfolgers. */
jti?: string;
},
): Promise<string> {
const jti = crypto.randomUUID();
const jti = opts.jti || crypto.randomUUID();
const familyId = opts.familyId || crypto.randomUUID();
const token = signRefreshToken(payload, jti, familyId);
const decoded: any = jwt.decode(token);
@@ -99,7 +106,13 @@ async function pruneExpiredRefreshTokens(): Promise<void> {
* Wirft mit `code = 'REFRESH_REPLAY'`, wenn ein bereits eingeloester Token
* erneut auftaucht - der Controller meldet das als Sicherheitsvorfall.
*/
async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> {
async function consumeRefreshJti(decoded: any): Promise<{
familyId: string;
/** Frische Rotation: Datensatz fuer diese jti muss noch angelegt werden. */
issueJti?: string;
/** Kulanz: dieser bereits ausgestellte Nachfolger wird erneut ausgegeben. */
reuseJti?: string;
}> {
const jti: string | undefined = decoded?.jti;
const fam: string | undefined = decoded?.fam;
@@ -129,17 +142,21 @@ async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> {
// sehen und durchgelassen (im Test kamen 90 gleichzeitige Requests
// ausnahmslos durch). Deshalb wird der Zustandswechsel als bedingtes UPDATE
// ausgefuehrt - die Datenbank entscheidet, wer zuerst war.
// Die jti des Nachfolgers wird SCHON HIER festgelegt und im selben UPDATE
// hinterlegt. Dadurch weiss eine spaetere Wiedervorlage, welcher Nachfolger
// bereits ausgestellt wurde - Grundlage der idempotenten Kulanz (R168-01).
const nachfolgerJti = crypto.randomUUID();
const beansprucht = await prisma.refreshTokenRecord.updateMany({
where: { jti, usedAt: null, revokedAt: null },
data: { usedAt: new Date() },
data: { usedAt: new Date(), replacedByJti: nachfolgerJti },
});
if (beansprucht.count === 1) {
return { familyId: rec.familyId };
return { familyId: rec.familyId, issueJti: nachfolgerJti };
}
// Bereits eingeloest. Innerhalb des engen Kulanzfensters und nur begrenzt oft
// tolerieren (parallele Tabs) - ebenfalls als bedingtes UPDATE, damit die
// Obergrenze unter Last wirklich haelt.
// Bereits eingeloest. Innerhalb des engen Kulanzfensters begrenzt tolerieren
// (parallele Tabs) - als bedingtes UPDATE, damit die Obergrenze unter Last
// wirklich haelt.
const fensterAb = new Date(Date.now() - REFRESH_REUSE_GRACE_MS);
const toleriert = await prisma.refreshTokenRecord.updateMany({
where: {
@@ -151,7 +168,32 @@ async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> {
data: { reuseCount: { increment: 1 } },
});
if (toleriert.count === 1) {
return { familyId: rec.familyId };
// IDEMPOTENT: denselben, bereits ausgestellten Nachfolger zurueckgeben -
// NICHT erneut rotieren (Pentest R168-01).
//
// Vorher entstand bei jeder Kulanz-Wiedervorlage eine frische Linie mit
// eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token
// konnte damit aus dem erkennbaren Replay-Zustand in eine eigene,
// sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des
// Opfers kollidiert - also dauerhaft unsichtbar blieb.
//
// Jetzt laufen parallele Tabs auf DIESELBE Linie zusammen. Wer den Token
// spaeter erneut vorlegt (also ausserhalb des Fensters), kollidiert
// zwangslaeufig und loest den Familien-Widerruf aus.
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
if (aktuell?.replacedByJti) {
const nachfolger = await prisma.refreshTokenRecord.findUnique({
where: { jti: aktuell.replacedByJti },
});
if (nachfolger?.revokedAt) {
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
err.code = 'REFRESH_REVOKED';
throw err;
}
return { familyId: rec.familyId, reuseJti: aktuell.replacedByJti };
}
// Kein Nachfolger hinterlegt: nicht ersatzweise rotieren (das waere genau
// der Fork). Fail-closed als Replay behandeln.
}
// Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen
@@ -444,7 +486,20 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
}
// Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02).
// Wirft bei Replay danach ist die gesamte Familie widerrufen.
const { familyId } = await consumeRefreshJti(decoded);
const rotation = await consumeRefreshJti(decoded);
// Bei Kulanz wird derselbe, bereits ausgestellte Nachfolger erneut signiert -
// ohne neuen Datensatz, damit keine zweite Linie entsteht (R168-01).
const naechsterRefreshToken = async (
payload: JwtPayload,
subjekt: { userId?: number; customerId?: number; isCustomerPortal: boolean },
): Promise<string> =>
rotation.reuseJti
? signRefreshToken(payload, rotation.reuseJti, rotation.familyId)
: issueRefreshToken(payload, {
...subjekt,
familyId: rotation.familyId,
jti: rotation.issueJti,
});
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
// Mitarbeiter
@@ -476,7 +531,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
accessToken: signAccessToken(payload),
// Nachfolger bleibt in derselben Familie ein Replay des Vorgaengers
// sprengt damit auch alle daraus entstandenen Tokens.
refreshToken: await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false, familyId }),
refreshToken: await naechsterRefreshToken(payload, { userId: user.id, isCustomerPortal: false }),
user: {
id: user.id,
email: user.email,
@@ -507,10 +562,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
};
return {
accessToken: signAccessToken(payload),
refreshToken: await issueRefreshToken(payload, {
refreshToken: await naechsterRefreshToken(payload, {
customerId: customer.id,
isCustomerPortal: true,
familyId,
}),
user: portalUser,
};
+28
View File
@@ -97,6 +97,34 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔒 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01, HIGH)** (2026-08-18)
- Der Pentester hat genau die Frage beantwortet, die ich beim Uebergeben
gestellt hatte („laesst sich das Kulanzfenster ausnutzen?“) und zwar
nicht per Timing, sondern per **Linien-Fork**: Jede Kulanz-Wiedervorlage
rotierte auf einen FRISCHEN Token mit eigenem, zurueckgesetztem Zaehler.
Ein Angreifer mit gestohlenem Token konnte damit aus dem erkennbaren
Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung entkommen,
die nie wieder mit der des Opfers kollidiert **dauerhaft unsichtbar,
kein einziges CRITICAL**. Damit war die Kern-Garantie von R164-02
(Diebstahl faellt bei der naechsten Nutzung auf) ausgehebelt.
- Fix (sein Vorschlag): **Kulanz idempotent**. Die jti des Nachfolgers wird
schon beim Einloesen im selben bedingten UPDATE reserviert
(`replacedByJti`). Eine Wiedervorlage im Fenster gibt **denselben** bereits
ausgestellten Nachfolger zurueck, statt neu zu rotieren ohne neuen
Datensatz. Parallele Tabs laufen dadurch auf EINE Linie zusammen; wer den
Token spaeter (ausserhalb des Fensters) vorlegt, kollidiert zwangslaeufig
und loest den Familien-Widerruf aus.
Ist kein Nachfolger hinterlegt, wird bewusst NICHT ersatzweise rotiert
(das waere wieder der Fork), sondern fail-closed als Replay gewertet.
- Verifiziert sein PoC nachgebaut: T0 legit → TA, T0 replayt → TC;
**TA.jti == TC.jti**, also kein Fork mehr. Ueber HTTP: zwei parallele Tabs
beide erfolgreich und auf derselben Linie; gestohlener Token nach Ablauf
des Fensters → 401, Familie widerrufen, Angreifer-Linie tot,
`SUSPICIOUS/CRITICAL` gemeldet.
- Regression der in R168 bestaetigten Faelle: 40 parallel → 4 erfolgreich
(1 + Kulanz 3) auf **einer** Linie, Rest abgewiesen; serielles Replay 14
ok, 5. → Replay; Token ohne jti → fail-closed; Logout widerruft. `tsc` gruen.
- [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