diff --git a/src/lib/server/rate-limiter.js b/src/lib/server/rate-limiter.js index 534a4f2a..6d866483 100644 --- a/src/lib/server/rate-limiter.js +++ b/src/lib/server/rate-limiter.js @@ -97,6 +97,13 @@ export const smsGlobalDailyLimiter = new RateLimiter({ windowMs: 24 * 60 * 60 * 1000 }); +export function parseTrustedProxyCount(value = process.env.TRUSTED_PROXY_COUNT) { + const raw = String(value ?? '0').trim(); + if (!/^\d+$/.test(raw)) return 0; + const count = Number(raw); + return Number.isSafeInteger(count) ? count : 0; +} + /** * Extract client IP from a Next.js Request object. * @@ -121,7 +128,7 @@ export const smsGlobalDailyLimiter = new RateLimiter({ * @returns {string} */ export function getClientIp(request) { - const trustedProxyCount = parseInt(process.env.TRUSTED_PROXY_COUNT ?? '0', 10); + const trustedProxyCount = parseTrustedProxyCount(); if (trustedProxyCount > 0) { // Take the Nth-from-right entry in X-Forwarded-For where N = trustedProxyCount. diff --git a/src/lib/server/rate-limiter.test.js b/src/lib/server/rate-limiter.test.js new file mode 100644 index 00000000..9e63c034 --- /dev/null +++ b/src/lib/server/rate-limiter.test.js @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getClientIp, parseTrustedProxyCount } from './rate-limiter.js'; + +function requestWithHeaders(headers) { + return { + headers: { + get(name) { + return headers[name.toLowerCase()] ?? null; + } + } + }; +} + +describe('trusted proxy parsing', () => { + const original = process.env.TRUSTED_PROXY_COUNT; + + afterEach(() => { + if (original === undefined) { + delete process.env.TRUSTED_PROXY_COUNT; + } else { + process.env.TRUSTED_PROXY_COUNT = original; + } + }); + + it('accepts only unsigned integer proxy counts', () => { + expect(parseTrustedProxyCount('2')).toBe(2); + expect(parseTrustedProxyCount(' 01 ')).toBe(1); + expect(parseTrustedProxyCount('1abc')).toBe(0); + expect(parseTrustedProxyCount('-1')).toBe(0); + expect(parseTrustedProxyCount('')).toBe(0); + }); + + it('does not trust X-Forwarded-For when the proxy count is malformed', () => { + process.env.TRUSTED_PROXY_COUNT = '1abc'; + const request = requestWithHeaders({ + 'x-forwarded-for': '203.0.113.9, 198.51.100.10', + 'x-real-ip': '192.0.2.55' + }); + + expect(getClientIp(request)).toBe('192.0.2.55'); + }); + + it('uses the nth trusted hop from the right when configured', () => { + process.env.TRUSTED_PROXY_COUNT = '2'; + const request = requestWithHeaders({ + 'x-forwarded-for': '203.0.113.9, 198.51.100.10, 192.0.2.55' + }); + + expect(getClientIp(request)).toBe('198.51.100.10'); + }); +}); diff --git a/src/middleware.js b/src/middleware.js index 70d8b915..478ec159 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -1,33 +1,11 @@ import { NextResponse } from 'next/server'; -import { RateLimiter } from '@/lib/server/rate-limiter.js'; +import { getClientIp, RateLimiter } from '@/lib/server/rate-limiter.js'; const authRateLimiter = new RateLimiter({ maxRequests: 10, windowMs: 60 * 1000 }); const webhookRateLimiter = new RateLimiter({ maxRequests: 100, windowMs: 60 * 1000 }); const apiRateLimiter = new RateLimiter({ maxRequests: 60, windowMs: 60 * 1000 }); const keyBackupRateLimiter = new RateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 1000 }); -function getClientIp(request) { - // See the detailed note in src/lib/server/rate-limiter.js. - // X-Forwarded-For is user-controllable unless a trusted proxy strips/rewrites it. - // Honour TRUSTED_PROXY_COUNT when set; otherwise fall back to X-Real-IP only. - const trustedProxyCount = parseInt(process.env.TRUSTED_PROXY_COUNT ?? '0', 10); - - if (trustedProxyCount > 0) { - const xff = request.headers.get('x-forwarded-for'); - if (xff) { - const parts = xff.split(',').map(s => s.trim()).filter(Boolean); - if (parts.length >= trustedProxyCount) { - return parts[parts.length - trustedProxyCount]; - } - } - } - - return ( - request.headers.get('x-real-ip') ?? - 'unknown' - ); -} - function rateLimitedResponse() { return NextResponse.json( { error: 'Too many requests. Please try again later.' },