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,81 @@
|
||||
export interface FailedAttemptLimiterOptions {
|
||||
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
|
||||
maxFailures: number;
|
||||
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
|
||||
windowMs: number;
|
||||
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
|
||||
maxTrackedSources: number;
|
||||
clock?: () => number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
failures: number;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
|
||||
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
|
||||
*
|
||||
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
|
||||
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
|
||||
* das ist für eine Bremse gegen Durchprobieren ausreichend.
|
||||
*/
|
||||
export class FailedAttemptLimiter {
|
||||
private readonly windows = new Map<string, Window>();
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(private readonly options: FailedAttemptLimiterOptions) {
|
||||
this.clock = options.clock ?? Date.now;
|
||||
}
|
||||
|
||||
isBlocked(source: string): boolean {
|
||||
const window = this.activeWindow(source);
|
||||
return window !== undefined && window.failures >= this.options.maxFailures;
|
||||
}
|
||||
|
||||
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
|
||||
recordFailure(source: string): boolean {
|
||||
const now = this.clock();
|
||||
const current = this.activeWindow(source);
|
||||
if (!current) {
|
||||
this.makeRoom(now);
|
||||
}
|
||||
const failures = (current?.failures ?? 0) + 1;
|
||||
this.windows.set(source, {
|
||||
failures,
|
||||
startedAt: current?.startedAt ?? now,
|
||||
});
|
||||
return failures === this.options.maxFailures;
|
||||
}
|
||||
|
||||
trackedSources(): number {
|
||||
return this.windows.size;
|
||||
}
|
||||
|
||||
private activeWindow(source: string): Window | undefined {
|
||||
const window = this.windows.get(source);
|
||||
if (window && this.clock() - window.startedAt > this.options.windowMs) {
|
||||
this.windows.delete(source);
|
||||
return undefined;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private makeRoom(now: number): void {
|
||||
if (this.windows.size < this.options.maxTrackedSources) return;
|
||||
for (const [source, window] of this.windows) {
|
||||
if (now - window.startedAt > this.options.windowMs) {
|
||||
this.windows.delete(source);
|
||||
}
|
||||
}
|
||||
// Immer noch voll: den ältesten Eintrag verdrängen (Map behält die
|
||||
// Einfügereihenfolge). Lieber eine Zählung verlieren als unbegrenzt wachsen.
|
||||
if (this.windows.size >= this.options.maxTrackedSources) {
|
||||
for (const oldest of this.windows.keys()) {
|
||||
this.windows.delete(oldest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user