5d21574c81
48.3 (Rate-Limit fehlt): POST /api/users/:id/password verlangt seit 47.3 die Eingabe des eigenen Admin-Passworts. Ohne Throttle könnte ein Angreifer mit gestohlenem JWT die Re-Auth per Brute-Force aushebeln. - Neuer staffPasswordReAuthLimiter (5 Versuche / 10 min, bucket: IP + target-user-id, skipSuccessfulRequests: true) - emit SecurityEvent RATE_LIMIT_HIT severity HIGH - Vor authenticate gemounted, damit auch unauth-Spamming begrenzt wird 48.4 (Alter Token überlebt Self-Reset): Nach erfolgreichem Setzen wird tokenInvalidatedAt des Ziel-Users auf jetzt gesetzt. Greift besonders bei Self-Reset (Admin setzt sich selbst zurück) – ein zuvor gestohlenes Token wird sofort ungültig, statt bis zum natürlichen Ablauf (15 min) brauchbar zu bleiben. Die bestehende Auth-Middleware liest tokenInvalidatedAt bereits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
5.3 KiB
TypeScript
142 lines
5.3 KiB
TypeScript
/**
|
||
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
|
||
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
|
||
*
|
||
* Wenn ein Limit überschritten wird, emit() wir zusätzlich ein
|
||
* SecurityEvent (RATE_LIMIT_HIT) – damit der Monitoring-View und das
|
||
* Alert-System sehen, wenn jemand auf die Tür hämmert.
|
||
*/
|
||
import rateLimit from 'express-rate-limit';
|
||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||
|
||
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
|
||
return (req: any, _res: any) => {
|
||
const ctx = contextFromRequest(req);
|
||
emitSecurityEvent({
|
||
type: 'RATE_LIMIT_HIT',
|
||
severity,
|
||
message: `Rate-Limit überschritten: ${label}`,
|
||
ipAddress: ctx.ipAddress,
|
||
userEmail: req.body?.email,
|
||
endpoint: ctx.endpoint,
|
||
details: { limiter: label },
|
||
});
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Login-Limiter: 10 Fehlversuche pro 15 min PRO (IP + Email)-Tuple.
|
||
*
|
||
* Das Bucket ist gezielt das Paar, nicht IP allein und nicht Email allein:
|
||
* - IP allein wäre kein Schutz: ein Angreifer wechselt Proxy, hat wieder
|
||
* 10 freie Versuche gegen den gleichen Account.
|
||
* - Email allein erzeugt False-Positives (Familie hinter NAT: Max
|
||
* vertippt sich → Nina kommt von gleicher IP nicht mehr rein) und
|
||
* macht Account-Lockout-DoS möglich (Angreifer sperrt fremde Accounts
|
||
* aus, indem er von beliebigen IPs falsche PWs gegen sie probiert).
|
||
* - Tuple (IP, Email): Max kann sich nicht mehr einloggen, Nina von
|
||
* gleicher IP schon. Max von einer anderen IP auch, solange er das
|
||
* richtige PW hat – ihre eigene Spur in den Buckets ist sauber.
|
||
*
|
||
* keyGenerator → `${ip}|${email-lowercase}`. Bei fehlender Email
|
||
* (z.B. komplett leerer Body) Fallback nur auf IP, damit kein
|
||
* Single-Shared-Bucket entsteht.
|
||
*/
|
||
export const loginRateLimiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
limit: 10,
|
||
standardHeaders: 'draft-7',
|
||
legacyHeaders: false,
|
||
message: {
|
||
success: false,
|
||
error: 'Zu viele Login-Versuche für diese Kombination aus Account und IP. Bitte in 15 Minuten erneut versuchen.',
|
||
},
|
||
skipSuccessfulRequests: true,
|
||
keyGenerator: (req): string => {
|
||
const email = (req.body?.email || '').toString().trim().toLowerCase();
|
||
const ip = req.ip || 'unknown';
|
||
return email ? `${ip}|${email}` : `${ip}|<no-email>`;
|
||
},
|
||
handler: (req, res, _next, options) => {
|
||
onLimitReached('login', 'HIGH')(req, res);
|
||
res.status(options.statusCode).json(options.message);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP.
|
||
* Verhindert Mail-Flut und gezielte Brute-Force über Reset-Links.
|
||
*/
|
||
export const passwordResetRateLimiter = rateLimit({
|
||
windowMs: 60 * 60 * 1000, // 1 Stunde
|
||
limit: 5,
|
||
standardHeaders: 'draft-7',
|
||
legacyHeaders: false,
|
||
message: {
|
||
success: false,
|
||
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
|
||
},
|
||
handler: (req, res, _next, options) => {
|
||
onLimitReached('password-reset', 'MEDIUM')(req, res);
|
||
res.status(options.statusCode).json(options.message);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Staff-Password-Set-Limiter (Pentest 48.3, 2026-06-01):
|
||
* POST /api/users/:id/password verlangt seit 47.3 die Eingabe des eigenen
|
||
* Admin-Passworts (`currentPassword`). Ohne Throttle könnte ein Angreifer
|
||
* mit gestohlenem JWT die 25-Zeichen-Passwort-Policy zwar nicht erraten,
|
||
* aber kürzere/typische Admin-Passwörter (z.B. Stagings, kompromittierte
|
||
* Setups) per Brute-Force durchprobieren – und damit den Re-Auth-Fix
|
||
* komplett aushebeln.
|
||
*
|
||
* Bucket: (IP, target-user-id). Damit walked ein Angreifer pro Opfer
|
||
* langsam und kann nicht mit einem stolen-token gegen alle Staff-User
|
||
* parallel anrennen. `skipSuccessfulRequests: true`, weil legitime
|
||
* Passwort-Resets nicht den Counter füllen sollen.
|
||
*/
|
||
export const staffPasswordReAuthLimiter = rateLimit({
|
||
windowMs: 10 * 60 * 1000, // 10 Minuten
|
||
limit: 5,
|
||
standardHeaders: 'draft-7',
|
||
legacyHeaders: false,
|
||
message: {
|
||
success: false,
|
||
error: 'Zu viele fehlgeschlagene Passwort-Set-Versuche. Bitte in 10 Minuten erneut versuchen.',
|
||
},
|
||
skipSuccessfulRequests: true,
|
||
keyGenerator: (req): string => {
|
||
const ip = req.ip || 'unknown';
|
||
const targetUserId = (req.params?.id ?? '<missing>').toString();
|
||
return `${ip}|staff-pw|${targetUserId}`;
|
||
},
|
||
handler: (req, res, _next, options) => {
|
||
onLimitReached('staff-password-set', 'HIGH')(req, res);
|
||
res.status(options.statusCode).json(options.message);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
|
||
* unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk,
|
||
* aber DoS-Vektor: ohne Limit könnte ein Angreifer endlos POSTen und
|
||
* den Service durch Audit-Log-Spam + Mail-Versand belasten.
|
||
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
|
||
* für legitime Kunden weit aus.
|
||
*/
|
||
export const publicConsentRateLimiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
limit: 30,
|
||
standardHeaders: 'draft-7',
|
||
legacyHeaders: false,
|
||
message: {
|
||
success: false,
|
||
error: 'Zu viele Anfragen. Bitte in 15 Minuten erneut versuchen.',
|
||
},
|
||
handler: (req, res, _next, options) => {
|
||
onLimitReached('public-consent', 'MEDIUM')(req, res);
|
||
res.status(options.statusCode).json(options.message);
|
||
},
|
||
});
|