Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)
Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen, um unter der LOGIN_FAILED-Schwelle zu bleiben. Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent via emit). Fix daher an zwei Ebenen: - Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert), ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert bewusst nicht (normaler Erstbesuch). - Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW + Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH (semantisch ein Refresh, kein Login). Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -362,9 +362,25 @@ export async function refresh(req: Request, res: Response): Promise<void> {
|
|||||||
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
||||||
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
||||||
clearRefreshCookie(res);
|
clearRefreshCookie(res);
|
||||||
|
// Detection (Pentest R164-01): ein vorgelegter, aber abgelehnter Refresh-Token
|
||||||
|
// ist potenzieller Replay/Brute-Force → TOKEN_REJECTED emittieren, damit die
|
||||||
|
// bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP → CRITICAL) greift.
|
||||||
|
// Severity wie beim Access-Token (auth-Middleware): abgelaufen/revoked = LOW
|
||||||
|
// (benigne, kein Sofort-Alert), ungültige Signatur/Manipulation = HIGH.
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
const code = (error as { code?: string })?.code;
|
||||||
|
const msg = error instanceof Error ? error.message : 'Refresh fehlgeschlagen';
|
||||||
|
const benign = code === 'REFRESH_EXPIRED' || /invalidiert/i.test(msg);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'TOKEN_REJECTED',
|
||||||
|
severity: benign ? 'LOW' : 'HIGH',
|
||||||
|
message: `Refresh-Token abgelehnt: ${msg}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
|
error: msg,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ function determineAction(method: string, path: string, success: boolean): AuditA
|
|||||||
if (path.includes('/auth/logout')) {
|
if (path.includes('/auth/logout')) {
|
||||||
return 'LOGOUT';
|
return 'LOGOUT';
|
||||||
}
|
}
|
||||||
// Stiller Token-Refresh (Cookie) – kein interaktiver Login, eigene Action
|
// Stiller Token-Refresh (Cookie) – kein interaktiver Login, eigene Action.
|
||||||
|
// Erfolg vs. Fehlschlag wird NICHT über die Action getrennt (semantisch beides
|
||||||
|
// ein Refresh), sondern downstream über success-Flag + Sensitivität (LOW/HIGH)
|
||||||
|
// und den TOKEN_REJECTED-SecurityEvent im Controller (Pentest R164-01).
|
||||||
if (path.includes('/auth/refresh')) {
|
if (path.includes('/auth/refresh')) {
|
||||||
return 'TOKEN_REFRESH';
|
return 'TOKEN_REFRESH';
|
||||||
}
|
}
|
||||||
@@ -203,7 +206,10 @@ function generateHumanLabel(
|
|||||||
: `Anmeldung fehlgeschlagen für ${email}`;
|
: `Anmeldung fehlgeschlagen für ${email}`;
|
||||||
}
|
}
|
||||||
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
||||||
if (path.includes('/auth/refresh')) return 'Sitzung verlängert (Token erneuert)';
|
if (path.includes('/auth/refresh')) {
|
||||||
|
const failed = responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false;
|
||||||
|
return failed ? 'Token-Refresh abgelehnt (ungültig/abgelaufen)' : 'Sitzung verlängert (Token erneuert)';
|
||||||
|
}
|
||||||
|
|
||||||
// Kunden-Operationen
|
// Kunden-Operationen
|
||||||
if (resourceType === 'Customer') {
|
if (resourceType === 'Customer') {
|
||||||
@@ -442,9 +448,11 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
|||||||
customerId: req.user?.customerId,
|
customerId: req.user?.customerId,
|
||||||
isCustomerPortal: req.user?.isCustomerPortal,
|
isCustomerPortal: req.user?.isCustomerPortal,
|
||||||
action,
|
action,
|
||||||
// Stiller Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
|
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
|
||||||
|
// Fehlgeschlagener Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in
|
||||||
|
// der Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
|
||||||
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
|
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
|
||||||
sensitivity: action === 'TOKEN_REFRESH' ? 'LOW' : undefined,
|
sensitivity: action === 'TOKEN_REFRESH' ? (responseSuccess ? 'LOW' : 'HIGH') : undefined,
|
||||||
resourceType: mapping.type,
|
resourceType: mapping.type,
|
||||||
resourceId,
|
resourceId,
|
||||||
resourceLabel,
|
resourceLabel,
|
||||||
|
|||||||
@@ -286,8 +286,12 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
|||||||
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
|
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (e) {
|
||||||
throw new Error('Refresh-Token ungültig oder abgelaufen');
|
// Code erhalten, damit der Controller abgelaufen (benigne, LOW) von
|
||||||
|
// manipuliert/ungültiger Signatur (verdächtig, HIGH) trennen kann.
|
||||||
|
const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
|
||||||
|
err.code = e instanceof jwt.TokenExpiredError ? 'REFRESH_EXPIRED' : 'REFRESH_INVALID';
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (decoded.type !== 'refresh') {
|
if (decoded.type !== 'refresh') {
|
||||||
throw new Error('Falscher Token-Typ');
|
throw new Error('Falscher Token-Typ');
|
||||||
|
|||||||
@@ -97,6 +97,30 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🛡️ Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18)
|
||||||
|
- Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos
|
||||||
|
`TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf
|
||||||
|
geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der
|
||||||
|
Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter
|
||||||
|
CRITICAL zu bleiben).
|
||||||
|
- **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zählt
|
||||||
|
`SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action →
|
||||||
|
`LOGIN_FAILED`) hätte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen:
|
||||||
|
- **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` →
|
||||||
|
greift die bestehende Schwelle `≥3 TOKEN_REJECTED/5min/IP → CRITICAL`
|
||||||
|
(securityAlert.service, kein Severity-Filter). Severity wie Access-Token:
|
||||||
|
abgelaufen/revoked → LOW (benigne, kein Sofort-Alert), ungültige
|
||||||
|
Signatur/Manipulation → HIGH (Sofort-Alert). `auth.service` reicht dafür
|
||||||
|
`err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. „Kein Cookie" emittiert
|
||||||
|
NICHT (normaler Erstbesuch).
|
||||||
|
- **Audit-Triage:** fehlgeschlagener Refresh → Sensitivität HIGH statt LOW +
|
||||||
|
Label „Token-Refresh abgelehnt (ungültig/abgelaufen)". Action bleibt
|
||||||
|
bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login).
|
||||||
|
- Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH; `tsc` grün.
|
||||||
|
- Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation
|
||||||
|
bietet keinen Replay-Schutz (alter Token bis exp gültig), aber fail-closed
|
||||||
|
nach Logout. Ggf. später: Refresh-Token-Jti-Blacklist / One-Time-Use.
|
||||||
|
|
||||||
- [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18)
|
- [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18)
|
||||||
- Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach
|
- Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach
|
||||||
Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` /
|
Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` /
|
||||||
|
|||||||
Reference in New Issue
Block a user