diff --git a/server/src/middleware/rate-limit.test.ts b/server/src/middleware/rate-limit.test.ts index 5ae7f6e..d0c7cc7 100644 --- a/server/src/middleware/rate-limit.test.ts +++ b/server/src/middleware/rate-limit.test.ts @@ -9,6 +9,8 @@ describe('rateLimit', () => { vi.resetModules() // Set production mode before importing so isDev = false process.env.NODE_ENV = 'production' + // Trust proxy headers in tests (like behind Cloudflare/nginx) + process.env.TRUSTED_PROXY = '1' const mod = await import('./rate-limit.js') rateLimit = mod.rateLimit cleanup = mod.cleanupInterval @@ -16,6 +18,7 @@ describe('rateLimit', () => { afterEach(() => { process.env.NODE_ENV = 'test' + delete process.env.TRUSTED_PROXY clearInterval(cleanup) vi.restoreAllMocks() }) @@ -117,6 +120,42 @@ describe('rateLimit', () => { vi.useRealTimers() }) + it('ignores proxy headers when TRUSTED_PROXY is not set', async () => { + // Re-import without TRUSTED_PROXY + vi.resetModules() + process.env.NODE_ENV = 'production' + delete process.env.TRUSTED_PROXY + const mod = await import('./rate-limit.js') + const rl = mod.rateLimit + cleanup = mod.cleanupInterval + + const app = new Hono() + app.get('/test', rl(10_000, 2), (c) => c.json({ ok: true })) + + // All requests use 'unknown' as key (no proxy headers trusted) + // so they share the same bucket + const res1 = await app.request('/test', { headers: { 'x-forwarded-for': '1.1.1.1' } }) + expect(res1.status).toBe(200) + const res2 = await app.request('/test', { headers: { 'x-forwarded-for': '2.2.2.2' } }) + expect(res2.status).toBe(200) + // 3rd request hits limit because all mapped to 'unknown' + const res3 = await app.request('/test', { headers: { 'x-forwarded-for': '3.3.3.3' } }) + expect(res3.status).toBe(429) + }) + + it('trusts proxy headers when TRUSTED_PROXY is set', async () => { + const app = new Hono() + app.get('/test', rateLimit(10_000, 2), (c) => c.json({ ok: true })) + + // Different X-Forwarded-For IPs are tracked independently + const res1 = await app.request('/test', { headers: { 'x-forwarded-for': '1.1.1.1' } }) + expect(res1.status).toBe(200) + const res2 = await app.request('/test', { headers: { 'x-forwarded-for': '2.2.2.2' } }) + expect(res2.status).toBe(200) + const res3 = await app.request('/test', { headers: { 'x-forwarded-for': '3.3.3.3' } }) + expect(res3.status).toBe(200) // different IP, still allowed + }) + 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 6fddd9b..f9cfc3f 100644 --- a/server/src/middleware/rate-limit.ts +++ b/server/src/middleware/rate-limit.ts @@ -34,10 +34,21 @@ export const cleanupInterval = setInterval(() => { }, 5 * 60 * 1000) function getIp(c: Context): string { - return c.req.header('cf-connecting-ip') - || c.req.header('x-real-ip') - || c.req.header('x-forwarded-for')?.split(',')[0].trim() - || 'unknown' + // Only trust proxy headers when TRUSTED_PROXY is configured + if (process.env.TRUSTED_PROXY) { + const cfIp = c.req.header('cf-connecting-ip') + if (cfIp) return cfIp + const realIp = c.req.header('x-real-ip') + if (realIp) return realIp + const xff = c.req.header('x-forwarded-for') + if (xff) return xff.split(',')[0].trim() + } + // Fall back to connection IP from Node.js socket + const incoming = (c.env as Record)?.incoming + if (incoming?.socket?.remoteAddress) { + return incoming.socket.remoteAddress + } + return 'unknown' } export function rateLimit(windowMs: number, maxHits: number) {