File size: 2,233 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
export const AUTH_RATE_LIMIT_CODE = 42901;
export const AUTH_RATE_LIMIT_MSG = 'Too many failed auth attempts';

export interface AuthFailureLimiterOptions {
  readonly maxFailures?: number;
  readonly windowMs?: number;
  readonly banMs?: number;
  readonly logger?: { warn(obj: unknown, msg: string): void };
}

export interface AuthFailureLimiter {
  recordFailure(ip: string): void;
  isBanned(ip: string): boolean;
  dispose(): void;
}

interface Entry {
  count: number;
  windowStart: number;
  bannedUntil: number;
}

const DEFAULT_MAX_FAILURES = 10;
const DEFAULT_WINDOW_MS = 60_000;
const DEFAULT_BAN_MS = 60_000;

export function createAuthFailureLimiter(
  opts?: AuthFailureLimiterOptions,
): AuthFailureLimiter {
  const maxFailures = opts?.maxFailures ?? DEFAULT_MAX_FAILURES;
  const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
  const banMs = opts?.banMs ?? DEFAULT_BAN_MS;
  const entries = new Map<string, Entry>();

  const sweep = setInterval(() => {
    const now = Date.now();
    for (const [ip, entry] of entries) {
      const banned = entry.bannedUntil > now;
      const windowLive = now - entry.windowStart <= windowMs;
      if (!banned && !windowLive) {
        entries.delete(ip);
      }
    }
  }, windowMs);
  if (typeof sweep.unref === 'function') {
    sweep.unref();
  }

  return {
    recordFailure(ip: string): void {
      const now = Date.now();
      let entry = entries.get(ip);
      if (entry === undefined || now - entry.windowStart > windowMs) {
        entry = { count: 0, windowStart: now, bannedUntil: 0 };
        entries.set(ip, entry);
      }
      entry.count += 1;
      if (entry.count >= maxFailures) {
        const wasBanned = entry.bannedUntil > now;
        entry.bannedUntil = now + banMs;
        if (!wasBanned) {
          opts?.logger?.warn(
            { ip, bannedUntil: entry.bannedUntil },
            'too many failed auth attempts; source temporarily banned',
          );
        }
      }
    },
    isBanned(ip: string): boolean {
      const entry = entries.get(ip);
      if (entry === undefined) return false;
      return entry.bannedUntil > Date.now();
    },
    dispose(): void {
      clearInterval(sweep);
      entries.clear();
    },
  };
}