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:
@@ -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({
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user