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({
|
||||
|
||||
Reference in New Issue
Block a user