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);
+30
View File
@@ -97,6 +97,36 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🛡️ Login-Rate-Limit jetzt pro (IP + Email)-Tupel**
- Vorher reine IP-basierte Sperre, was zwei Schwächen hatte:
a) Familie hinter NAT: Max vertippt sich → Nina kommt nicht rein
b) Angreifer wechselt Proxy → wieder 10 freie Versuche pro
Account, dieselbe IP-only-Sperre umgangen.
- Eine reine Email-Sperre wurde verworfen wegen Account-Lockout-
DoS (jeder kann fremde Accounts sperren) + denselben Shared-IP-
Problem.
- **Lösung**: Bucket-Key ist `${ip}|${email-lowercase}`. Damit:
* Max von IP-A 10x vergeigt → (IP-A, max) gesperrt
* Nina von IP-A → eigenes Bucket (IP-A, nina), unbetroffen
* Admin von IP-A mit richtigem PW → erfolgreicher Login
* Max von IP-B → eigenes Bucket (IP-B, max), darf wieder
- Implementation: `loginRateLimiter.keyGenerator = ${ip}|${email}`
in `middleware/rateLimit.ts`; nur ein Limiter, kein zusätzlicher
Email-only.
- Admin-UI: Listing zeigt Tupel (IP, Email), Reset schickt
beides mit, Audit-Log resourceId = `${ip}|${email}`.
- **Live-verifiziert** (4 Schritte):
11x falsch max → 429, Nina/Admin von gleicher IP → durch,
max bleibt gesperrt, Reset → max wieder 401.
- [x] **🚨 PUT /customers/:id/portal mit `password` im Body → 400**
- Endpoint nahm `password` silent entgegen, ignorierte es, gab
aber HTTP 200 zurück → Client glaubte fälschlich, das Passwort
sei gesetzt. Fix: explizite Body-Validierung `password`,
`portalPassword`, `portalPasswordHash`, `portalPasswordEncrypted`
sind verbotene Felder, HTTP 400 mit Hinweis auf den dedizierten
`POST /portal/password`-Endpoint.
- [x] **🚨 Pentest Runde 17 JWT-TTL + Pentest-Marker-Detection**
- **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files,
die noch die alte Konvention vor der Refresh-Token-Trennung
+17 -15
View File
@@ -31,9 +31,11 @@ export default function RateLimits() {
});
const resetMutation = useMutation({
mutationFn: (ip: string) => rateLimitApi.reset(ip),
onSuccess: (_, ip) => {
toast.success(`Rate-Limit für ${ip} freigegeben`);
mutationFn: (e: ActiveRateLimit) =>
rateLimitApi.reset({ ipAddress: e.ipAddress, email: e.email || undefined }),
onSuccess: (_, e) => {
const label = e.email ? `${e.ipAddress} + ${e.email}` : e.ipAddress;
toast.success(`Rate-Limit für ${label} freigegeben`);
qc.invalidateQueries({ queryKey: ['rate-limits-active'] });
},
onError: (err) => {
@@ -58,8 +60,9 @@ export default function RateLimits() {
Rate-Limit-Sperren
</h1>
<p className="text-sm text-gray-600 mt-1">
IP-Adressen, die durch den Login- oder Passwort-Reset-Rate-Limiter
in den letzten 15 Minuten gesperrt wurden.
Gesperrte (IP + Account)-Paare aus den letzten 15 Minuten.
Andere Accounts von derselben IP sind nicht betroffen, und der
gesperrte Account kann sich weiter von einer anderen IP einloggen.
</p>
</div>
</div>
@@ -83,7 +86,7 @@ export default function RateLimits() {
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">IP-Adresse</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Letzter Versuch (E-Mail)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Account (E-Mail)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Limiter</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Hits</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zuletzt</th>
@@ -92,13 +95,13 @@ export default function RateLimits() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{entries.map((e) => (
<tr key={e.ipAddress}>
<tr key={`${e.ipAddress}|${e.email || ''}`}>
<td className="px-4 py-3 font-mono text-sm">{e.ipAddress}</td>
<td className="px-4 py-3 text-sm">
{e.lastEmail ? (
<span className="font-mono">{e.lastEmail}</span>
{e.email ? (
<span className="font-mono">{e.email}</span>
) : (
<span className="text-gray-400"></span>
<span className="text-gray-400"> (kein Account)</span>
)}
</td>
<td className="px-4 py-3 text-sm">
@@ -117,7 +120,7 @@ export default function RateLimits() {
<Button
size="sm"
variant="primary"
onClick={() => resetMutation.mutate(e.ipAddress)}
onClick={() => resetMutation.mutate(e)}
disabled={resetMutation.isPending}
>
<Unlock className="w-4 h-4 mr-1" />
@@ -131,10 +134,9 @@ export default function RateLimits() {
</div>
)}
<div className="p-4 text-xs text-gray-500 border-t bg-gray-50">
Hinweis: Die Liste basiert auf den <strong>Security-Events</strong> der
letzten 15 Minuten (Rate-Limit-Fenster). Eine Freigabe leert sowohl den
Login- als auch den Passwort-Reset-Limiter für diese IP und wird im
Audit-Log protokolliert.
Hinweis: Der Limiter sperrt pro (IP, Account)-Paar andere Accounts
von derselben IP bzw. derselbe Account von einer anderen IP sind
nicht betroffen. Jede Freigabe wird im Audit-Log protokolliert.
</div>
</Card>
</div>
+3 -3
View File
@@ -1015,9 +1015,9 @@ export const backupApi = {
// Rate-Limit-Verwaltung (Admin)
export interface ActiveRateLimit {
ipAddress: string;
email: string | null;
lastHit: string;
hitCount: number;
lastEmail: string | null;
lastEndpoint: string | null;
limiters: string[];
}
@@ -1026,8 +1026,8 @@ export const rateLimitApi = {
const res = await api.get<ApiResponse<ActiveRateLimit[]>>('/settings/rate-limits/active');
return res.data;
},
reset: async (ipAddress: string) => {
const res = await api.post<ApiResponse<void>>('/settings/rate-limits/reset', { ipAddress });
reset: async (body: { ipAddress: string; email?: string }) => {
const res = await api.post<ApiResponse<void>>('/settings/rate-limits/reset', body);
return res.data;
},
};