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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user