Files
opencrm/backend/src/middleware/rateLimit.ts
T
duffyduckandClaude Opus 4.7 b4dfede494 Rate-Limiting: IPv6-Bypass-Härtung via ipKeyGenerator
loginRateLimiter und staffPasswordReAuthLimiter keyten auf die volle
req.ip. Bei IPv6 kann ein Angreifer aus seinem zugeteilten Block
(/56–/64) pro Request eine neue Adresse nehmen und so das Per-IP-Limit
umgehen – betrifft Login-Bruteforce-Schutz und den Passwort-Set-
Reauth-Limiter.

Fix: req.ip in beiden keyGenerator durch ipKeyGenerator() ersetzt
(express-rate-limit v7). IPv6 wird auf das Subnetz normalisiert
(Library-Default /56), IPv4 bleibt unverändert. Verifiziert: zwei
verschiedene IPv6 im selben /56 ergeben denselben Key.

Die Limiter ohne eigenen keyGenerator (Passwort-Reset, Consent)
normalisieren IPv6 bereits über den Library-Default – damit jetzt
konsistent. Ersetzt den verworfenen aria-WIP, neu auf aktuellem
Stand gebaut.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 16:38:54 +02:00

149 lines
5.8 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.
*/
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);
},
});