Login-Rate-Limit pro (IP + Email)-Tupel + PUT /portal verbietet password
Login-Rate-Limit:
Bucket-Key jetzt `${ip}|${email-lowercase}`, ein Limiter (10/15min).
Vorher IP-only oder Email-only führten beide zu Problemen:
- IP-only: Proxy-Wechsel umgeht Sperre auf Account-Ebene
- Email-only: Familie hinter NAT (Max vertippt sich → Nina blockiert),
Account-Lockout-DoS möglich
- Tupel: Max gesperrt, Nina von gleicher IP weiterhin frei, Max von
anderer IP auch noch, eigener Account bleibt erreichbar.
Implementation:
- middleware/rateLimit.ts: keyGenerator → ip|email
- routes/auth.routes.ts: nur ein loginRateLimiter am /login + /customer-login
- controllers/rateLimitAdmin.controller.ts: Listing als (IP, Email)-
Tupel, Reset nimmt ipAddress + optional email. Audit-resourceId =
ip|email (gleich wie Bucket-Key) → Listing kann Reset herausfiltern.
- frontend/RateLimits.tsx: Tabelle mit IP- und Account-Spalte,
Reset-Button schickt beides.
PUT /customers/:id/portal:
Body-Felder password/portalPassword/portalPasswordHash/
portalPasswordEncrypted werden explizit mit 400 abgelehnt. Vorher
wurden sie silent ignoriert + HTTP 200, was den Client glauben ließ,
das PW sei gesetzt. Hinweis im Error-Body zeigt auf den dedizierten
POST /portal/password-Endpoint.
Live-verifiziert:
- 11x falsch max@x.de → 429
- Nina/Admin von gleicher IP → durch
- Reset (IP, max) → max wieder 401 statt 429
- PUT /portal {password:"abcd"} → 400 "Felder nicht erlaubt"
- PUT /portal ohne password → 200
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,20 +25,38 @@ function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
|
||||
}
|
||||
|
||||
/**
|
||||
* Login: 10 Versuche pro 15 Minuten pro IP.
|
||||
* Nach Überschreitung: 15 Min Sperre für diese IP.
|
||||
* 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, // 15 Minuten
|
||||
limit: 10, // Max. 10 Versuche pro Zeitfenster
|
||||
windowMs: 15 * 60 * 1000,
|
||||
limit: 10,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
success: false,
|
||||
error: 'Zu viele Login-Versuche. Bitte in 15 Minuten erneut versuchen.',
|
||||
error: 'Zu viele Login-Versuche für diese Kombination aus Account und IP. Bitte in 15 Minuten erneut versuchen.',
|
||||
},
|
||||
// Erfolgreiche Logins zählen nicht gegen das Limit
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user