65ec07e274
28.1 URI-Schema unvollstaendig: DANGEROUS_URI_SCHEMES erweitert um file:/ftp: – "ftp://evil.com/x.js" und "file:///etc/passwd" wurden vorher in companyName akzeptiert. 28.2 HTML-Entity-Decoding-Bypass: stripHtml() lief direkt ueber den Roh-String, "javascript:", "<script>" und "<script>" umgingen die Regex. decodeHtmlEntities() dekodiert jetzt numerische (decimal+hex) + gaengige named entities VOR dem Tag-/URI-Strip. 28.3 Vollmacht-Upload Magic-Byte-Check: multer pruefte nur client-MIME, HTML/PHP/Shell-Scripts kamen als application/pdf durch. uploadAuthorizationDocument liest jetzt die ersten 5 Bytes und verlangt "%PDF-", sonst Loeschen + 400. 28.4 Rate-Limit auf /api/public/consent: 30 Requests pro IP pro 15min. Brute-Force-sicher war der 128-bit- UUID-Hash schon, aber ohne Limit konnte ein Angreifer das System mit Audit-Log- und Mail-Spam belasten. Live-verifiziert auf dev: alle vier Bypaesse blockiert, legitime Eingaben unangetastet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
3.9 KiB
TypeScript
107 lines
3.9 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);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* 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);
|
||
},
|
||
});
|