Rate-Limit-Sperren: Admin-UI zum Freigeben
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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 },
|
||||
},
|
||||
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<string, Active>();
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
||||
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
||||
<Route path="settings/monitoring" element={<Monitoring />} />
|
||||
<Route path="settings/rate-limits" element={<RateLimits />} />
|
||||
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
||||
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
||||
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('settings:read') && (
|
||||
<Link
|
||||
to="/settings/rate-limits"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-orange-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-orange-50 rounded-lg group-hover:bg-orange-100 transition-colors">
|
||||
<ShieldOff className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-orange-600 transition-colors flex items-center gap-2">
|
||||
Rate-Limit-Sperren
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Aktuell gesperrte IP-Adressen anzeigen und ggf. wieder freigeben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('gdpr:admin') && (
|
||||
<Link
|
||||
to="/settings/gdpr"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { rateLimitApi, type ActiveRateLimit } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import { ArrowLeft, RefreshCw, ShieldOff, Unlock } from 'lucide-react';
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const diffMin = Math.floor((Date.now() - d.getTime()) / 60000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin === 1) return 'vor 1 Minute';
|
||||
return `vor ${diffMin} Minuten`;
|
||||
}
|
||||
|
||||
function limiterLabel(name: string): string {
|
||||
if (name === 'login') return 'Login';
|
||||
if (name === 'password-reset') return 'Passwort-Reset';
|
||||
return name;
|
||||
}
|
||||
|
||||
export default function RateLimits() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['rate-limits-active'],
|
||||
queryFn: () => 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 (
|
||||
<div className="container mx-auto p-6 max-w-5xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/settings')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
Zurück
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldOff className="w-6 h-6 text-orange-600" />
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-gray-500">Lade...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<ShieldOff className="w-10 h-10 mx-auto text-gray-300 mb-2" />
|
||||
Aktuell keine Sperren aktiv.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<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">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>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{entries.map((e) => (
|
||||
<tr key={e.ipAddress}>
|
||||
<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>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{e.limiters.map((l) => (
|
||||
<span
|
||||
key={l}
|
||||
className="inline-block bg-orange-100 text-orange-800 text-xs px-2 py-0.5 rounded mr-1"
|
||||
>
|
||||
{limiterLabel(l)}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{e.hitCount}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{formatTime(e.lastHit)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => resetMutation.mutate(e.ipAddress)}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
<Unlock className="w-4 h-4 mr-1" />
|
||||
Freigeben
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<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 });
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Platforms
|
||||
export const platformApi = {
|
||||
getAll: async (includeInactive = false) => {
|
||||
|
||||
Reference in New Issue
Block a user