diff --git a/lib/server/rateLimit.ts b/lib/server/rateLimit.ts index 2606ba8..a27c535 100644 --- a/lib/server/rateLimit.ts +++ b/lib/server/rateLimit.ts @@ -1,7 +1,47 @@ import redis from "./redis"; +// Per-instance, volatile fallback windows used only when Redis is unreachable. +// They reset on restart and are not shared across instances, so they are +// best-effort defense-in-depth rather than a global guarantee. The point is +// that a Redis outage must not turn the limiter into a fully open door: +// without this, an attacker who can detect a Redis blip gets unlimited login +// and registration attempts. +const fallbackWindows = new Map(); +const FALLBACK_MAX_KEYS = 50_000; + +function fallbackRateLimit( + key: string, + limit: number, + windowMs: number +): { success: boolean; reset: number; remaining: number } { + const now = Date.now(); + + // Prune expired windows once the map grows large so a long outage with many + // distinct keys (one per IP) can't grow memory without bound. + if (fallbackWindows.size > FALLBACK_MAX_KEYS) { + for (const [k, window] of fallbackWindows) { + if (window.resetAt <= now) fallbackWindows.delete(k); + } + } + + const existing = fallbackWindows.get(key); + if (!existing || existing.resetAt <= now) { + const window = { count: 1, resetAt: now + windowMs }; + fallbackWindows.set(key, window); + return { success: true, reset: window.resetAt, remaining: limit - 1 }; + } + + existing.count += 1; + return { + success: existing.count <= limit, + reset: existing.resetAt, + remaining: Math.max(0, limit - existing.count), + }; +} + /** - * Basic fixed-window rate limiter using Redis. + * Basic fixed-window rate limiter using Redis, with an in-process fallback + * when Redis is unreachable. * @param key The unique key to rate limit (e.g. "rl:login:ip:127.0.0.1") * @param limit Maximum number of allowed requests in the window * @param windowMs The time window in milliseconds @@ -19,24 +59,24 @@ export async function rateLimit( try { results = await multi.exec(); } catch { - // Fail open on a Redis connection error rather than locking everyone out. - return { success: true, reset: Date.now() + windowMs, remaining: limit }; + // Redis unreachable: fall back to the in-process limiter rather than + // failing fully open. + return fallbackRateLimit(key, limit, windowMs); } if (!results) { - // If multi fails, fail open to prevent locking out users on Redis transient errors - return { success: true, reset: Date.now() + windowMs, remaining: limit }; + return fallbackRateLimit(key, limit, windowMs); } - + const count = results[0][1] as number; let ttl = results[1][1] as number; - + if (ttl === -1 || ttl === -2) { // Key has no expiration or didn't exist when INCR ran await redis.pexpire(key, windowMs); ttl = windowMs; } - + return { success: count <= limit, reset: Date.now() + ttl, diff --git a/tests/unit/rate-limit.test.ts b/tests/unit/rate-limit.test.ts index 354be08..3b3f723 100644 --- a/tests/unit/rate-limit.test.ts +++ b/tests/unit/rate-limit.test.ts @@ -10,6 +10,7 @@ const fakeRedis = { counts: new Map(), expires: new Map(), failNextMulti: false, + alwaysFailMulti: false, pttlOf(key: string): number { const exp = this.expires.get(key); if (exp === undefined) return -1; @@ -29,6 +30,9 @@ const fakeRedis = { return chain; }, async exec(): Promise<[unknown, number][] | null> { + if (self.alwaysFailMulti) { + return null; + } if (self.failNextMulti) { self.failNextMulti = false; return null; @@ -52,6 +56,7 @@ const fakeRedis = { this.counts.clear(); this.expires.clear(); this.failNextMulti = false; + this.alwaysFailMulti = false; }, }; @@ -100,11 +105,37 @@ describe('rateLimit', () => { expect(overflow.remaining).toBe(0); }); - it('fails open when multi.exec() returns null (transient redis error)', async () => { + it('falls back to the in-process limiter on a transient redis error', async () => { fakeRedis.failNextMulti = true; const result = await rateLimit('rl:test:transient', 5, 1000); + // The fallback still counts the request rather than failing fully open. expect(result.success).toBe(true); - expect(result.remaining).toBe(5); + expect(result.remaining).toBe(4); + }); + + it('enforces the limit in-process while redis is unavailable', async () => { + fakeRedis.alwaysFailMulti = true; + const key = 'rl:test:fallback-enforce'; + for (let i = 0; i < 3; i++) { + expect((await rateLimit(key, 3, 10_000)).success).toBe(true); + } + const overflow = await rateLimit(key, 3, 10_000); + expect(overflow.success).toBe(false); + expect(overflow.remaining).toBe(0); + }); + + it('resets the in-process window after it expires', async () => { + vi.useFakeTimers(); + try { + fakeRedis.alwaysFailMulti = true; + const key = 'rl:test:fallback-reset'; + expect((await rateLimit(key, 1, 1000)).success).toBe(true); + expect((await rateLimit(key, 1, 1000)).success).toBe(false); + vi.advanceTimersByTime(1001); + expect((await rateLimit(key, 1, 1000)).success).toBe(true); + } finally { + vi.useRealTimers(); + } }); it('isolates buckets by key', async () => {