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> { export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); 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 // Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({ 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 { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
import { logChange } from '../services/audit.service.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; 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- * Listet aktive Sperren als (IP, Email)-Tupel. Jedes Tupel ist ein eigener
* Limiter ausgelöst haben. Pro IP: letzter Versuch, Anzahl Hits, * Bucket im Limiter Reset gilt exakt für dieses Paar.
* (letzte) versuchte E-Mail.
*/ */
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> { export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
try { try {
const since = new Date(Date.now() - LOGIN_WINDOW_MS); const since = new Date(Date.now() - LOGIN_WINDOW_MS);
const events = await prisma.securityEvent.findMany({ const events = await prisma.securityEvent.findMany({
where: { where: { type: 'RATE_LIMIT_HIT', createdAt: { gte: since } },
type: 'RATE_LIMIT_HIT',
createdAt: { gte: since },
ipAddress: { not: null },
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { select: {
ipAddress: true, 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 const byKey = new Map<string, ActiveLock>();
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) { 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 limiter = (ev.details as any)?.limiter ?? 'unknown';
const existing = byIp.get(ip); const key = lockKey(ip, email);
const existing = byKey.get(key);
if (existing) { if (existing) {
existing.hitCount += 1; existing.hitCount += 1;
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter); if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
} else { } else {
byIp.set(ip, { byKey.set(key, {
ipAddress: ip, ipAddress: ip,
email,
lastHit: ev.createdAt, lastHit: ev.createdAt,
hitCount: 1, hitCount: 1,
lastEmail: ev.userEmail,
lastEndpoint: ev.endpoint, lastEndpoint: ev.endpoint,
limiters: [limiter], limiters: [limiter],
}); });
} }
} }
// Bereits manuell freigegebene IPs aus der Anzeige rauswerfen: wenn der // Bereits manuell freigegebene aus der Liste werfen. Reset-Audit-Logs
// letzte Reset (= Audit-Log-Eintrag) NACH dem letzten Hit liegt, ist die // nutzen resourceId = "<ip>|<email>" (gleicher Schlüssel wie Bucket).
// IP nicht mehr gesperrt. SecurityEvents sind unveränderlich, also brauchen const candidates = Array.from(byKey.entries()).map(([k, e]) => ({
// wir diesen Reset-Marker, sonst bleibt eine bereits freigegebene IP mapKey: k,
// weiterhin im Bildschirm hängen, bis das 15-Min-Fenster abgelaufen ist. resourceId: k,
const candidateIps = Array.from(byIp.keys()); lastHit: e.lastHit,
if (candidateIps.length > 0) { }));
if (candidates.length > 0) {
const recentResets = await prisma.auditLog.findMany({ const recentResets = await prisma.auditLog.findMany({
where: { where: {
resourceType: 'RateLimit', resourceType: 'RateLimit',
resourceId: { in: candidateIps }, resourceId: { in: candidates.map((c) => c.resourceId) },
createdAt: { gte: since }, createdAt: { gte: since },
}, },
select: { resourceId: true, createdAt: true }, select: { resourceId: true, createdAt: true },
@@ -79,20 +79,15 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom
}); });
const resetMap = new Map<string, Date>(); const resetMap = new Map<string, Date>();
for (const r of recentResets) { for (const r of recentResets) {
if (r.resourceId && !resetMap.has(r.resourceId)) { if (r.resourceId && !resetMap.has(r.resourceId)) resetMap.set(r.resourceId, r.createdAt);
resetMap.set(r.resourceId, r.createdAt);
}
} }
for (const ip of candidateIps) { for (const c of candidates) {
const reset = resetMap.get(ip); const reset = resetMap.get(c.resourceId);
const entry = byIp.get(ip)!; if (reset && reset >= c.lastHit) byKey.delete(c.mapKey);
if (reset && reset >= entry.lastHit) {
byIp.delete(ip);
}
} }
} }
const list = Array.from(byIp.values()).sort( const list = Array.from(byKey.values()).sort(
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(), (a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
); );
res.json({ success: true, data: list } as ApiResponse); 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). * Reset für ein konkretes (IP, Email)-Tupel. Body MUSS ipAddress enthalten
* Idempotent: wenn die IP nicht im Store ist, bleibt der Aufruf einfach * + optional email. Bei fehlender Email wird `<ip>|<no-email>` reseted
* ohne Wirkung. * (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> { export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
try { try {
const ip = (req.body?.ipAddress || '').toString().trim(); const ip = (req.body?.ipAddress || '').toString().trim();
const email = (req.body?.email || '').toString().trim().toLowerCase();
if (!ip) { if (!ip) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'IP-Adresse fehlt', error: 'IP-Adresse erforderlich',
} as ApiResponse); } as ApiResponse);
return; return;
} }
// express-rate-limit v7 exponiert resetKey() auf dem Middleware-Handle.
// Falls die IP nicht im Store ist, ist das ein No-Op. // Login-Tuple-Bucket: `${ip}|${email}` bzw. `${ip}|<no-email>`
await (loginRateLimiter as any).resetKey?.(ip); 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); 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({ await logChange({
req, req,
action: 'UPDATE', action: 'UPDATE',
resourceType: 'RateLimit', resourceType: 'RateLimit',
resourceId: ip, resourceId: audited,
label: `Rate-Limit für IP ${ip} manuell freigegeben`, 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) { } catch (error) {
console.error('resetRateLimit error:', error); console.error('resetRateLimit error:', error);
res.status(500).json({ 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. * Login-Limiter: 10 Fehlversuche pro 15 min PRO (IP + Email)-Tuple.
* Nach Überschreitung: 15 Min Sperre für diese IP. *
* 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({ export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 Minuten windowMs: 15 * 60 * 1000,
limit: 10, // Max. 10 Versuche pro Zeitfenster limit: 10,
standardHeaders: 'draft-7', standardHeaders: 'draft-7',
legacyHeaders: false, legacyHeaders: false,
message: { message: {
success: false, 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, 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) => { handler: (req, res, _next, options) => {
onLimitReached('login', 'HIGH')(req, res); onLimitReached('login', 'HIGH')(req, res);
res.status(options.statusCode).json(options.message); res.status(options.statusCode).json(options.message);
+5
View File
@@ -5,6 +5,11 @@ import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLi
const router = Router(); 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('/login', loginRateLimiter, authController.login);
router.post('/customer-login', loginRateLimiter, authController.customerLogin); router.post('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/refresh', authController.refresh); router.post('/refresh', authController.refresh);
+30
View File
@@ -97,6 +97,36 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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** - [x] **🚨 Pentest Runde 17 JWT-TTL + Pentest-Marker-Detection**
- **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files, - **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files,
die noch die alte Konvention vor der Refresh-Token-Trennung 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({ const resetMutation = useMutation({
mutationFn: (ip: string) => rateLimitApi.reset(ip), mutationFn: (e: ActiveRateLimit) =>
onSuccess: (_, ip) => { rateLimitApi.reset({ ipAddress: e.ipAddress, email: e.email || undefined }),
toast.success(`Rate-Limit für ${ip} freigegeben`); 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'] }); qc.invalidateQueries({ queryKey: ['rate-limits-active'] });
}, },
onError: (err) => { onError: (err) => {
@@ -58,8 +60,9 @@ export default function RateLimits() {
Rate-Limit-Sperren Rate-Limit-Sperren
</h1> </h1>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
IP-Adressen, die durch den Login- oder Passwort-Reset-Rate-Limiter Gesperrte (IP + Account)-Paare aus den letzten 15 Minuten.
in den letzten 15 Minuten gesperrt wurden. Andere Accounts von derselben IP sind nicht betroffen, und der
gesperrte Account kann sich weiter von einer anderen IP einloggen.
</p> </p>
</div> </div>
</div> </div>
@@ -83,7 +86,7 @@ export default function RateLimits() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <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">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">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">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-left text-xs font-medium text-gray-500 uppercase">Zuletzt</th>
@@ -92,13 +95,13 @@ export default function RateLimits() {
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{entries.map((e) => ( {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 font-mono text-sm">{e.ipAddress}</td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">
{e.lastEmail ? ( {e.email ? (
<span className="font-mono">{e.lastEmail}</span> <span className="font-mono">{e.email}</span>
) : ( ) : (
<span className="text-gray-400"></span> <span className="text-gray-400"> (kein Account)</span>
)} )}
</td> </td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">
@@ -117,7 +120,7 @@ export default function RateLimits() {
<Button <Button
size="sm" size="sm"
variant="primary" variant="primary"
onClick={() => resetMutation.mutate(e.ipAddress)} onClick={() => resetMutation.mutate(e)}
disabled={resetMutation.isPending} disabled={resetMutation.isPending}
> >
<Unlock className="w-4 h-4 mr-1" /> <Unlock className="w-4 h-4 mr-1" />
@@ -131,10 +134,9 @@ export default function RateLimits() {
</div> </div>
)} )}
<div className="p-4 text-xs text-gray-500 border-t bg-gray-50"> <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 Hinweis: Der Limiter sperrt pro (IP, Account)-Paar andere Accounts
letzten 15 Minuten (Rate-Limit-Fenster). Eine Freigabe leert sowohl den von derselben IP bzw. derselbe Account von einer anderen IP sind
Login- als auch den Passwort-Reset-Limiter für diese IP und wird im nicht betroffen. Jede Freigabe wird im Audit-Log protokolliert.
Audit-Log protokolliert.
</div> </div>
</Card> </Card>
</div> </div>
+3 -3
View File
@@ -1015,9 +1015,9 @@ export const backupApi = {
// Rate-Limit-Verwaltung (Admin) // Rate-Limit-Verwaltung (Admin)
export interface ActiveRateLimit { export interface ActiveRateLimit {
ipAddress: string; ipAddress: string;
email: string | null;
lastHit: string; lastHit: string;
hitCount: number; hitCount: number;
lastEmail: string | null;
lastEndpoint: string | null; lastEndpoint: string | null;
limiters: string[]; limiters: string[];
} }
@@ -1026,8 +1026,8 @@ export const rateLimitApi = {
const res = await api.get<ApiResponse<ActiveRateLimit[]>>('/settings/rate-limits/active'); const res = await api.get<ApiResponse<ActiveRateLimit[]>>('/settings/rate-limits/active');
return res.data; return res.data;
}, },
reset: async (ipAddress: string) => { reset: async (body: { ipAddress: string; email?: string }) => {
const res = await api.post<ApiResponse<void>>('/settings/rate-limits/reset', { ipAddress }); const res = await api.post<ApiResponse<void>>('/settings/rate-limits/reset', body);
return res.data; return res.data;
}, },
}; };