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:
2026-05-18 21:18:59 +02:00
parent 0f2dc44e45
commit 2cb6f172c9
7 changed files with 158 additions and 77 deletions
+16 -1
View File
@@ -921,7 +921,22 @@ export async function getPortalSettings(req: AuthRequest, res: Response): Promis
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const { portalEnabled, portalEmail } = req.body;
// `password` (oder password-ähnliche Felder) gehören NICHT in den
// Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt
// (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer
// ein Passwort setzen will, nutzt POST /portal/password mit
// Komplexitäts-Check. (Pentest-Befund.)
const body = req.body || {};
const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted'];
const offending = forbidden.filter((k) => k in body);
if (offending.length > 0) {
res.status(400).json({
success: false,
error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`,
} as ApiResponse);
return;
}
const { portalEnabled, portalEmail } = body;
// Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({
@@ -4,24 +4,30 @@ import { AuthRequest, ApiResponse } from '../types/index.js';
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
import { logChange } from '../services/audit.service.js';
// Login-Rate-Limiter sperrt 15 Minuten. Wir betrachten alles, was innerhalb
// dieses Fensters einen RATE_LIMIT_HIT erzeugt hat, als „aktuell gesperrt".
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
type ActiveLock = {
ipAddress: string;
email: string | null; // null = Passwort-Reset oder Login ohne Email
lastHit: Date;
hitCount: number;
lastEndpoint: string | null;
limiters: string[]; // 'login' / 'password-reset'
};
function lockKey(ip: string, email: string | null): string {
return `${ip}|${(email || '').toLowerCase()}`;
}
/**
* Listet alle IP-Adressen, die in den letzten 15 Minuten den Login-Rate-
* Limiter ausgelöst haben. Pro IP: letzter Versuch, Anzahl Hits,
* (letzte) versuchte E-Mail.
* Listet aktive Sperren als (IP, Email)-Tupel. Jedes Tupel ist ein eigener
* Bucket im Limiter Reset gilt exakt für dieses Paar.
*/
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
try {
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
const events = await prisma.securityEvent.findMany({
where: {
type: 'RATE_LIMIT_HIT',
createdAt: { gte: since },
ipAddress: { not: null },
},
where: { type: 'RATE_LIMIT_HIT', createdAt: { gte: since } },
orderBy: { createdAt: 'desc' },
select: {
ipAddress: true,
@@ -32,46 +38,40 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom
},
});
// Pro IP gruppieren: lastHit + hitCount + zuletzt versuchte Email + Limiter-Typ
type Active = {
ipAddress: string;
lastHit: Date;
hitCount: number;
lastEmail: string | null;
lastEndpoint: string | null;
limiters: string[]; // 'login' / 'password-reset'
};
const byIp = new Map<string, Active>();
const byKey = new Map<string, ActiveLock>();
for (const ev of events) {
const ip = ev.ipAddress!;
const ip = ev.ipAddress || 'unknown';
const email = (ev.userEmail || '').toLowerCase() || null;
const limiter = (ev.details as any)?.limiter ?? 'unknown';
const existing = byIp.get(ip);
const key = lockKey(ip, email);
const existing = byKey.get(key);
if (existing) {
existing.hitCount += 1;
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
} else {
byIp.set(ip, {
byKey.set(key, {
ipAddress: ip,
email,
lastHit: ev.createdAt,
hitCount: 1,
lastEmail: ev.userEmail,
lastEndpoint: ev.endpoint,
limiters: [limiter],
});
}
}
// Bereits manuell freigegebene IPs aus der Anzeige rauswerfen: wenn der
// letzte Reset (= Audit-Log-Eintrag) NACH dem letzten Hit liegt, ist die
// IP nicht mehr gesperrt. SecurityEvents sind unveränderlich, also brauchen
// wir diesen Reset-Marker, sonst bleibt eine bereits freigegebene IP
// weiterhin im Bildschirm hängen, bis das 15-Min-Fenster abgelaufen ist.
const candidateIps = Array.from(byIp.keys());
if (candidateIps.length > 0) {
// Bereits manuell freigegebene aus der Liste werfen. Reset-Audit-Logs
// nutzen resourceId = "<ip>|<email>" (gleicher Schlüssel wie Bucket).
const candidates = Array.from(byKey.entries()).map(([k, e]) => ({
mapKey: k,
resourceId: k,
lastHit: e.lastHit,
}));
if (candidates.length > 0) {
const recentResets = await prisma.auditLog.findMany({
where: {
resourceType: 'RateLimit',
resourceId: { in: candidateIps },
resourceId: { in: candidates.map((c) => c.resourceId) },
createdAt: { gte: since },
},
select: { resourceId: true, createdAt: true },
@@ -79,20 +79,15 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom
});
const resetMap = new Map<string, Date>();
for (const r of recentResets) {
if (r.resourceId && !resetMap.has(r.resourceId)) {
resetMap.set(r.resourceId, r.createdAt);
}
if (r.resourceId && !resetMap.has(r.resourceId)) resetMap.set(r.resourceId, r.createdAt);
}
for (const ip of candidateIps) {
const reset = resetMap.get(ip);
const entry = byIp.get(ip)!;
if (reset && reset >= entry.lastHit) {
byIp.delete(ip);
}
for (const c of candidates) {
const reset = resetMap.get(c.resourceId);
if (reset && reset >= c.lastHit) byKey.delete(c.mapKey);
}
}
const list = Array.from(byIp.values()).sort(
const list = Array.from(byKey.values()).sort(
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
);
res.json({ success: true, data: list } as ApiResponse);
@@ -106,34 +101,50 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom
}
/**
* Setzt das Rate-Limit für eine konkrete IP zurück (Login + Password-Reset).
* Idempotent: wenn die IP nicht im Store ist, bleibt der Aufruf einfach
* ohne Wirkung.
* Reset für ein konkretes (IP, Email)-Tupel. Body MUSS ipAddress enthalten
* + optional email. Bei fehlender Email wird `<ip>|<no-email>` reseted
* (für Login-Versuche mit leerem Body). Für Passwort-Reset-Limit wird der
* IP-only-Key (alter Stil) zusätzlich reseted.
*/
export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
try {
const ip = (req.body?.ipAddress || '').toString().trim();
const email = (req.body?.email || '').toString().trim().toLowerCase();
if (!ip) {
res.status(400).json({
success: false,
error: 'IP-Adresse fehlt',
error: 'IP-Adresse erforderlich',
} as ApiResponse);
return;
}
// express-rate-limit v7 exponiert resetKey() auf dem Middleware-Handle.
// Falls die IP nicht im Store ist, ist das ein No-Op.
await (loginRateLimiter as any).resetKey?.(ip);
// Login-Tuple-Bucket: `${ip}|${email}` bzw. `${ip}|<no-email>`
const loginKey = email ? `${ip}|${email}` : `${ip}|<no-email>`;
await (loginRateLimiter as any).resetKey?.(loginKey);
// Passwort-Reset-Limit ist (noch) IP-only auch zurücksetzen
await (passwordResetRateLimiter as any).resetKey?.(ip);
// Audit-Resource-ID = der Bucket-Key, damit getActiveRateLimits den
// Eintrag aus der Anzeige filtern kann.
const audited = `${ip}|${email || ''}`;
await logChange({
req,
action: 'UPDATE',
resourceType: 'RateLimit',
resourceId: ip,
label: `Rate-Limit für IP ${ip} manuell freigegeben`,
resourceId: audited,
label: email
? `Rate-Limit für (IP ${ip}, Email ${email}) manuell freigegeben`
: `Rate-Limit für IP ${ip} manuell freigegeben`,
});
res.json({ success: true, message: `Rate-Limit für ${ip} freigegeben` } as ApiResponse);
res.json({
success: true,
message: email
? `Rate-Limit für (${ip}, ${email}) freigegeben`
: `Rate-Limit für ${ip} freigegeben`,
} as ApiResponse);
} catch (error) {
console.error('resetRateLimit error:', error);
res.status(500).json({
+24 -6
View File
@@ -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);
+5
View File
@@ -5,6 +5,11 @@ import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLi
const router = Router();
// loginRateLimiter sperrt pro (IP + Email)-Tuple. Damit kann sich
// `nina` von derselben IP einloggen, auch wenn `max` dort gerade
// 10x vergeigt hat und umgekehrt darf `max` von einer anderen IP
// auch dann noch versuchen, wenn IP-A gerade sein Bucket verbrannt
// hat (Pentest 2026-05-18 Szenario).
router.post('/login', loginRateLimiter, authController.login);
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/refresh', authController.refresh);