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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 1–4
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user