Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions lib/server/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -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<string, { count: number; resetAt: number }>();
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
Expand All @@ -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,
Expand Down
35 changes: 33 additions & 2 deletions tests/unit/rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const fakeRedis = {
counts: new Map<string, number>(),
expires: new Map<string, number>(),
failNextMulti: false,
alwaysFailMulti: false,
pttlOf(key: string): number {
const exp = this.expires.get(key);
if (exp === undefined) return -1;
Expand All @@ -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;
Expand All @@ -52,6 +56,7 @@ const fakeRedis = {
this.counts.clear();
this.expires.clear();
this.failNextMulti = false;
this.alwaysFailMulti = false;
},
};

Expand Down Expand Up @@ -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 () => {
Expand Down