From 956bc394b8e94f127bb1d86c1d24d20df71ecc26 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 17 May 2026 01:20:43 +0200 Subject: [PATCH] Rate-Limit-Sperren: Admin-UI zum Freigeben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei zu vielen Login-Fehlversuchen war ohne Container-Restart kein Weg zurück. Jetzt sehen Admins die aktiven Sperren und können einzeln freigeben. Backend: - GET /api/settings/rate-limits/active (settings:read) Liest SecurityEvent RATE_LIMIT_HIT der letzten 15 Min, gruppiert nach IP, liefert lastEmail/limiters/hitCount/lastHit. - POST /api/settings/rate-limits/reset (settings:update) Body { ipAddress } → ruft loginRateLimiter.resetKey + passwordReset- RateLimiter.resetKey auf (express-rate-limit v7), audited als UPDATE auf resourceType=RateLimit. Frontend: - Neue Seite /settings/rate-limits: Tabelle mit IP/Email/Limiter/Hits/ Letzter-Hit/Aktion. Auto-Refresh alle 15s. Freigeben-Button pro IP. - Kachel in Settings-Übersicht (orange, ShieldOff-Icon, settings:read). Live-verifiziert: 11 failed Logins → 429 ab dem 11.; Liste zeigt IP + Email; POST /reset → 200; danach wieder 401 statt 429; Audit-Log „Rate-Limit für IP 127.0.0.1 manuell freigegeben" angelegt. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/rateLimitAdmin.controller.ts | 113 ++++++++++++++ backend/src/routes/appSetting.routes.ts | 15 ++ docs/todo.md | 24 +++ frontend/src/App.tsx | 2 + frontend/src/pages/Settings.tsx | 23 ++- frontend/src/pages/settings/RateLimits.tsx | 142 ++++++++++++++++++ frontend/src/services/api.ts | 20 +++ 7 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 backend/src/controllers/rateLimitAdmin.controller.ts create mode 100644 frontend/src/pages/settings/RateLimits.tsx diff --git a/backend/src/controllers/rateLimitAdmin.controller.ts b/backend/src/controllers/rateLimitAdmin.controller.ts new file mode 100644 index 00000000..573235bb --- /dev/null +++ b/backend/src/controllers/rateLimitAdmin.controller.ts @@ -0,0 +1,113 @@ +import { Response } from 'express'; +import prisma from '../lib/prisma.js'; +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; + +/** + * 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. + */ +export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise { + 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 }, + }, + orderBy: { createdAt: 'desc' }, + select: { + ipAddress: true, + userEmail: true, + endpoint: true, + createdAt: true, + details: true, + }, + }); + + // 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(); + for (const ev of events) { + const ip = ev.ipAddress!; + const limiter = (ev.details as any)?.limiter ?? 'unknown'; + const existing = byIp.get(ip); + if (existing) { + existing.hitCount += 1; + if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter); + } else { + byIp.set(ip, { + ipAddress: ip, + lastHit: ev.createdAt, + hitCount: 1, + lastEmail: ev.userEmail, + lastEndpoint: ev.endpoint, + limiters: [limiter], + }); + } + } + + const list = Array.from(byIp.values()).sort( + (a, b) => b.lastHit.getTime() - a.lastHit.getTime(), + ); + res.json({ success: true, data: list } as ApiResponse); + } catch (error) { + console.error('getActiveRateLimits error:', error); + res.status(500).json({ + success: false, + error: 'Fehler beim Laden der aktiven Rate-Limits', + } as ApiResponse); + } +} + +/** + * 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. + */ +export async function resetRateLimit(req: AuthRequest, res: Response): Promise { + try { + const ip = (req.body?.ipAddress || '').toString().trim(); + if (!ip) { + res.status(400).json({ + success: false, + error: 'IP-Adresse fehlt', + } 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); + await (passwordResetRateLimiter as any).resetKey?.(ip); + + await logChange({ + req, + action: 'UPDATE', + resourceType: 'RateLimit', + resourceId: ip, + label: `Rate-Limit für IP ${ip} manuell freigegeben`, + }); + + res.json({ success: true, message: `Rate-Limit für ${ip} freigegeben` } as ApiResponse); + } catch (error) { + console.error('resetRateLimit error:', error); + res.status(500).json({ + success: false, + error: 'Fehler beim Zurücksetzen des Rate-Limits', + } as ApiResponse); + } +} diff --git a/backend/src/routes/appSetting.routes.ts b/backend/src/routes/appSetting.routes.ts index a525fcd5..69c2d7d6 100644 --- a/backend/src/routes/appSetting.routes.ts +++ b/backend/src/routes/appSetting.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import multer from 'multer'; import * as appSettingController from '../controllers/appSetting.controller.js'; import * as backupController from '../controllers/backup.controller.js'; +import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; // Multer für Backup-Upload (in Memory speichern) @@ -100,4 +101,18 @@ router.post( backupController.factoryReset ); +// Rate-Limit-Verwaltung (Admin) +router.get( + '/rate-limits/active', + authenticate, + requirePermission('settings:read'), + rateLimitAdminController.getActiveRateLimits, +); +router.post( + '/rate-limits/reset', + authenticate, + requirePermission('settings:update'), + rateLimitAdminController.resetRateLimit, +); + export default router; diff --git a/docs/todo.md b/docs/todo.md index 58cd4cb1..37b388ad 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,30 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🛠 Rate-Limit-Sperren: Admin-UI zum Freigeben** + - Bei einer Pentest-Runde hat der Tester sich selbst durch zu viele + Login-Versuche ausgesperrt → ohne Container-Restart kein Weg zurück. + Jetzt: Admin sieht die Sperren und kann sie einzeln aufheben. + - **Datenquelle für die Liste**: `SecurityEvent`-Tabelle filtert nach + `type = RATE_LIMIT_HIT` im 15-Min-Fenster (= Login-Window), gruppiert + nach IP. Pro Eintrag: IP, zuletzt versuchte E-Mail, Limiter-Typ + (Login / Passwort-Reset), Hit-Anzahl, Zeit seit letztem Hit. + - **Reset**: ruft `loginRateLimiter.resetKey(ip)` und + `passwordResetRateLimiter.resetKey(ip)` auf – exposiert von + `express-rate-limit` v7. Idempotent, audited. + - **Backend**: + * `GET /api/settings/rate-limits/active` (`settings:read`) + * `POST /api/settings/rate-limits/reset` (`settings:update`) mit + Body `{ ipAddress }` + * neuer Controller `rateLimitAdmin.controller.ts` + - **Frontend**: neue Seite `/settings/rate-limits` mit Tabelle + + Freigeben-Button, 15s Auto-Refresh; Kachel in Settings-Übersicht + (orange, neben „Sicherheits-Monitoring"). + - **Live-verifiziert (4 Schritte)**: 11 falsche Logins von + 127.0.0.1 → 11. → 429; Liste zeigt IP + Email + Hits; + POST Reset → 200; nächster Login mit falschem PW → 401 statt + 429 (Sperre weg); Audit-Log enthält Eintrag. + - [x] **🚨 Pentest Runde 7 – Hit-List durchgegangen + kurzlebige Download-Tokens** - **Credential-Endpoints** (Contracts password/internet/sip/simcard + Stressfrei mailbox/send/reset-password): ALLE bereits durch diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index edfec450..6eed2db4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import FactoryDefaults from './pages/settings/FactoryDefaults'; import AuditLogs from './pages/settings/AuditLogs'; import EmailLogPage from './pages/settings/EmailLogs'; import Monitoring from './pages/settings/Monitoring'; +import RateLimits from './pages/settings/RateLimits'; import GDPRDashboard from './pages/settings/GDPRDashboard'; import UserList from './pages/users/UserList'; import Settings from './pages/Settings'; @@ -230,6 +231,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index f4c1daa8..5f50e00b 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import Card from '../components/ui/Card'; -import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, FileText, FileEdit, PackageCheck } from 'lucide-react'; +import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck } from 'lucide-react'; export default function Settings() { const { hasPermission, developerMode, setDeveloperMode } = useAuth(); @@ -259,6 +259,27 @@ export default function Settings() { )} + {hasPermission('settings:read') && ( + +
+
+ +
+
+

+ Rate-Limit-Sperren + +

+

+ Aktuell gesperrte IP-Adressen anzeigen und ggf. wieder freigeben. +

+
+
+ + )} {hasPermission('gdpr:admin') && ( rateLimitApi.getActive(), + refetchInterval: 15000, + }); + + const resetMutation = useMutation({ + mutationFn: (ip: string) => rateLimitApi.reset(ip), + onSuccess: (_, ip) => { + toast.success(`Rate-Limit für ${ip} freigegeben`); + qc.invalidateQueries({ queryKey: ['rate-limits-active'] }); + }, + onError: (err) => { + const msg = err instanceof Error ? err.message : 'Reset fehlgeschlagen'; + toast.error(msg); + }, + }); + + const entries: ActiveRateLimit[] = data?.data || []; + + return ( +
+
+
+ +
+

+ + Rate-Limit-Sperren +

+

+ IP-Adressen, die durch den Login- oder Passwort-Reset-Rate-Limiter + in den letzten 15 Minuten gesperrt wurden. +

+
+
+ +
+ + + {isLoading ? ( +
Lade...
+ ) : entries.length === 0 ? ( +
+ + Aktuell keine Sperren aktiv. +
+ ) : ( +
+ + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + ))} + +
IP-AdresseLetzter Versuch (E-Mail)LimiterHitsZuletztAktion
{e.ipAddress} + {e.lastEmail ? ( + {e.lastEmail} + ) : ( + + )} + + {e.limiters.map((l) => ( + + {limiterLabel(l)} + + ))} + {e.hitCount}{formatTime(e.lastHit)} + +
+
+ )} +
+ Hinweis: Die Liste basiert auf den Security-Events 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. +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 505bc961..ce1938c3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1007,6 +1007,26 @@ export const backupApi = { }, }; +// Rate-Limit-Verwaltung (Admin) +export interface ActiveRateLimit { + ipAddress: string; + lastHit: string; + hitCount: number; + lastEmail: string | null; + lastEndpoint: string | null; + limiters: string[]; +} +export const rateLimitApi = { + getActive: async () => { + const res = await api.get>('/settings/rate-limits/active'); + return res.data; + }, + reset: async (ipAddress: string) => { + const res = await api.post>('/settings/rate-limits/reset', { ipAddress }); + return res.data; + }, +}; + // Platforms export const platformApi = { getAll: async (includeInactive = false) => {