Files
opencrm/backend/src/middleware/rateLimit.ts
T
duffyduck 65ec07e274 Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)
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>
2026-05-20 17:46:15 +02:00

107 lines
3.9 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 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);
},
});