diff --git a/server/src/middleware/rate-limit.test.ts b/server/src/middleware/rate-limit.test.ts index cca2263..5ae7f6e 100644 --- a/server/src/middleware/rate-limit.test.ts +++ b/server/src/middleware/rate-limit.test.ts @@ -91,6 +91,32 @@ describe('rateLimit', () => { expect(res.status).toBe(200) }) + it('evicts oldest entries when map exceeds MAX_MAP_SIZE (10k benchmark)', async () => { + vi.useFakeTimers() + const app = new Hono() + // Window of 60s so entries don't expire during test + app.get('/test', rateLimit(60_000, 100), (c) => c.json({ ok: true })) + + // Fill with 10,001 unique IPs to exceed MAX_MAP_SIZE (10,000) + for (let i = 0; i < 10_001; i++) { + const ip = `${(i >> 24) & 0xff}.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i & 0xff}` + await app.request('/test', { headers: { 'x-real-ip': ip } }) + } + + // Trigger cleanup interval (every 5 min) + vi.advanceTimersByTime(5 * 60 * 1000) + + // First IP (0.0.0.0) should have been evicted — gets a fresh counter + const res = await app.request('/test', { headers: { 'x-real-ip': '0.0.0.0' } }) + expect(res.status).toBe(200) + + // Last IP (0.0.39.17) should still be tracked — counter increments + const last = await app.request('/test', { headers: { 'x-real-ip': '0.0.39.17' } }) + expect(last.status).toBe(200) // still under limit (count now 2) + + vi.useRealTimers() + }) + it('poll endpoint config: 429 after 30 requests in 1s window', async () => { const app = new Hono() // Matches the actual /poll endpoint configuration diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts index 4358e04..6fddd9b 100644 --- a/server/src/middleware/rate-limit.ts +++ b/server/src/middleware/rate-limit.ts @@ -14,18 +14,22 @@ export const cleanupInterval = setInterval(() => { for (const [key, entry] of map) { if (now > entry.resetAt) map.delete(key) } - // Evict oldest if over limit + // Evict oldest entries (Map iterates in insertion order) if (map.size > MAX_MAP_SIZE) { - const sorted = [...map.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt) - const toRemove = sorted.slice(0, map.size - MAX_MAP_SIZE) - for (const [key] of toRemove) map.delete(key) + let toRemove = map.size - MAX_MAP_SIZE + for (const key of map.keys()) { + if (toRemove-- <= 0) break + map.delete(key) + } } } - // Also clean bot hit counts + // Also clean bot hit counts (Map iterates in insertion order) if (botHitCounts.size > MAX_MAP_SIZE) { - const sorted = [...botHitCounts.entries()].sort((a, b) => a[1] - b[1]) - const toRemove = sorted.slice(0, botHitCounts.size - MAX_MAP_SIZE) - for (const [key] of toRemove) botHitCounts.delete(key) + let toRemove = botHitCounts.size - MAX_MAP_SIZE + for (const key of botHitCounts.keys()) { + if (toRemove-- <= 0) break + botHitCounts.delete(key) + } } }, 5 * 60 * 1000)