fix: only trust proxy headers in rate-limit when TRUSTED_PROXY is set

X-Forwarded-For, X-Real-IP, and CF-Connecting-IP headers were
blindly trusted, allowing attackers to bypass rate limiting by
spoofing different IPs. Now only trusted when TRUSTED_PROXY env
var is configured. Falls back to Node.js socket remoteAddress.

Add tests verifying proxy headers are ignored without TRUSTED_PROXY
and respected when it is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 09:23:10 +00:00
co-authored by Claude Opus 4.6
parent 0131949643
commit 5bf557ba6a
2 changed files with 54 additions and 4 deletions
+39
View File
@@ -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
+15 -4
View File
@@ -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<string, any>)?.incoming
if (incoming?.socket?.remoteAddress) {
return incoming.socket.remoteAddress
}
return 'unknown'
}
export function rateLimit(windowMs: number, maxHits: number) {