fix(auth): harden async login boundaries

This commit is contained in:
2026-08-17 00:00:34 +02:00
parent 29bfb41363
commit c34e01e9b9
8 changed files with 426 additions and 124 deletions
+38 -16
View File
@@ -27,30 +27,52 @@ interface LoginPayload {
remember: boolean;
}
class LoginFailureLimiter {
export class LoginFailureLimiter {
private readonly usernames = new Map<string, number[]>();
private readonly addresses = new Map<string, number[]>();
private readonly maximumEntries: number;
constructor(options: { maximumEntries?: number } = {}) {
this.maximumEntries = options.maximumEntries ?? MAX_LIMIT_ENTRIES;
}
isLimited(username: string, address: string, now = Date.now()): boolean {
return this.active(this.usernames, username, now).length >= 10
|| this.active(this.addresses, address, now).length >= 20;
return this.countActive(this.usernames, username, now) >= 10
|| this.countActive(this.addresses, address, now) >= 20;
}
recordFailure(username: string, address: string, now = Date.now()): void {
this.active(this.usernames, username, now).push(now);
this.active(this.addresses, address, now).push(now);
recordFailure(username: string, address: string, now = Date.now()): boolean {
this.pruneExpired(this.usernames, now);
this.pruneExpired(this.addresses, now);
const usernameAttempts = this.usernames.get(username) ?? [];
const addressAttempts = this.addresses.get(address) ?? [];
if (usernameAttempts.length >= 10 || addressAttempts.length >= 20) return false;
if ((!this.usernames.has(username) && this.usernames.size >= this.maximumEntries)
|| (!this.addresses.has(address) && this.addresses.size >= this.maximumEntries)) return false;
this.usernames.set(username, [...usernameAttempts, now]);
this.addresses.set(address, [...addressAttempts, now]);
return true;
}
private active(bucket: Map<string, number[]>, key: string, now: number): number[] {
const prior = bucket.get(key) ?? [];
const current = prior.filter((timestamp) => timestamp > now - TEN_MINUTES_MS);
if (current.length === 0) bucket.delete(key); else bucket.set(key, current);
if (!bucket.has(key) && bucket.size >= MAX_LIMIT_ENTRIES) {
const oldest = bucket.keys().next().value;
if (typeof oldest === "string") bucket.delete(oldest);
private countActive(bucket: ReadonlyMap<string, readonly number[]>, key: string, now: number): number {
const attempts = bucket.get(key);
if (!attempts) return 0;
const earliest = now - TEN_MINUTES_MS;
let count = 0;
for (const timestamp of attempts) {
if (timestamp > earliest) count += 1;
}
return count;
}
private pruneExpired(bucket: Map<string, number[]>, now: number): void {
const earliest = now - TEN_MINUTES_MS;
for (const [key, attempts] of bucket) {
const active = attempts.filter((timestamp) => timestamp > earliest);
if (active.length === 0) bucket.delete(key);
else if (active.length !== attempts.length) bucket.set(key, active);
}
if (!bucket.has(key)) bucket.set(key, current);
return current;
}
}
@@ -107,7 +129,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
}
if (verified === undefined) return loginLimited(reply);
if (!verified || !user || !user.enabled) {
limiter.recordFailure(normalizedUsername, sourceAddress);
if (!limiter.recordFailure(normalizedUsername, sourceAddress)) return loginLimited(reply);
return invalidCredentials(reply);
}