feat(api-keys): Fehlversuchs-Sperre je IP und Log ohne Schlüssel
Nach 20 abgelehnten API-Schlüsseln (fehlerhaft, unbekannt, widerrufen, abgelaufen) in 15 Minuten antwortet ApiCore der Absender-IP mit 429, auch für richtige Schlüssel - vor jeder Datenbankabfrage. Gleiches Budget wie der ERP-Export des Shops. Die Zählung liegt wie der Throttler im Speicher des Pods und ist auf 10.000 Adressen begrenzt. Abgelehnte Schlüssel werden mit Grund, öffentlichem Präfix (ack_xxxxxxxx) und IP protokolliert, nie mit dem Schlüssel. Die Suche bleibt über den SHA-256-Hash; neu sind Unit-Tests für authenticate (fehlend, unbekannt, widerrufen, abgelaufen, Scope-Filter, IP-Allowlist, Sperre). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d7f0d2c13b
commit
1a1e70c94b
4 files changed
+408
-9
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||
|
||||
describe('FailedAttemptLimiter', () => {
|
||||
let now: number;
|
||||
let limiter: FailedAttemptLimiter;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_000_000;
|
||||
limiter = new FailedAttemptLimiter({
|
||||
maxFailures: 3,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 100,
|
||||
clock: () => now,
|
||||
});
|
||||
});
|
||||
|
||||
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
|
||||
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
|
||||
limiter.recordFailure('203.0.113.7');
|
||||
limiter.recordFailure('203.0.113.7');
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
});
|
||||
|
||||
it('trennt Quellen voneinander', () => {
|
||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
|
||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
||||
now += 60_001;
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
});
|
||||
|
||||
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 1,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
now += 60_001;
|
||||
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
|
||||
small.recordFailure('c');
|
||||
expect(small.trackedSources()).toBe(1);
|
||||
expect(small.isBlocked('c')).toBe(true);
|
||||
});
|
||||
|
||||
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 5,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
small.recordFailure('c');
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user