Files
opencrm/backend/src/middleware/rateLimit.ts
T
duffyduckandClaude Opus 4.8 8e46dbbfed Globaler /api-Rate-Limit-Backstop (Pentest R148)
Es gab bislang keinen generellen Limiter - nur Login/Passwort-Reset/
Staff-ReAuth/Consent; authentifizierte Endpoints waren gegen Enumeration/
DoS ungedrosselt. Neuer apiBackstopRateLimiter auf alle /api-Requests,
vor den Routern gemountet (ergaenzt die feineren Limiter, ersetzt sie nicht).

Key = nur IPv6-/56-normalisierte IP - bewusst nicht IP+User, da der User-
Claim hier nur unverifiziert lesbar waere (authenticate laeuft erst pro
Route) und ein Angreifer sonst per Fake-userId beliebig Buckets erzeugen
koennte. Limit per Env API_RATE_LIMIT_PER_MIN (Default 1200/min, Floor 60),
/api/health ausgenommen. Kein SecurityEvent pro Block (Flood-Amplification).
Deckt zugleich den offenen IPv6-Rate-Limit-Test ausserhalb der Auth-Pfade ab.

Verifiziert: 60x200 dann 429, health bleibt 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:54:12 +02:00

190 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, { ipKeyGenerator } 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();
// IPv6-Härtung: ipKeyGenerator normalisiert IPv6 auf das Subnetz
// (Default /56), damit ein Angreifer nicht durch Rotation innerhalb
// seines zugeteilten IPv6-Blocks das Per-IP-Limit umgeht. IPv4 bleibt
// unverändert. Die Limiter OHNE eigenen keyGenerator (Passwort-Reset,
// Consent) machen das schon über den Library-Default hier holen wir
// die Custom-keyGenerator-Limiter auf denselben Stand.
const ip = ipKeyGenerator(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 => {
// IPv6-Härtung wie beim Login-Limiter (siehe dort).
const ip = ipKeyGenerator(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.
*/
/**
* Globaler Backstop-Limiter für ALLE /api-Requests (Pentest R148).
*
* Hintergrund: Es gab bislang KEINEN generellen /api-Limiter nur die
* dedizierten oben (Login, Passwort-Reset, Staff-Re-Auth, Consent). Damit war
* jeder authentifizierte Endpoint gegen Enumeration/Scripted-Abuse/DoS
* ungedrosselt. Dieser Limiter ist eine großzügige Obergrenze, KEIN Ersatz für
* die feineren Limiter (die feuern früher und bleiben aktiv).
*
* Key = NUR die (IPv6-/56-normalisierte) IP. Bewusst NICHT (IP+User): den
* User-Claim könnten wir hier nur unverifiziert aus dem Token lesen (die volle
* `authenticate`-Prüfung inkl. DB läuft erst pro Route). Ein Angreifer könnte
* dann mit gefälschten userId-Claims beliebig frische Buckets erzeugen und den
* Backstop umgehen. Per-Account-Präzision liefern ohnehin die Login-Limiter.
*
* Limit per Env `API_RATE_LIMIT_PER_MIN` (Default 1200/min ≈ 20/s pro IP)
* für legitime Nutzung (auch mehrere Nutzer hinter NAT) weit ausreichend,
* bremst aber Flooding massiv. Healthcheck (`/api/health`) ist ausgenommen.
*
* KEIN SecurityEvent pro geblocktem Request: Bei einem Flood würde das den
* Security-/Audit-Store selbst zumüllen (Amplification). Die 429 stehen im
* Access-Log; die feineren Limiter melden weiterhin an das Monitoring.
*/
const API_RATE_LIMIT_PER_MIN = Math.max(
parseInt(process.env.API_RATE_LIMIT_PER_MIN || '', 10) || 1200,
60,
);
export const apiBackstopRateLimiter = rateLimit({
windowMs: 60 * 1000,
limit: API_RATE_LIMIT_PER_MIN,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Anfragen in kurzer Zeit. Bitte einen Moment warten.',
},
keyGenerator: (req): string => ipKeyGenerator(req.ip || 'unknown'),
skip: (req) => req.path === '/health',
});
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);
},
});