From adb1627cc21a0548f137628e5bb9533e28fcb712 Mon Sep 17 00:00:00 2001 From: katnisscalls99 Date: Sat, 6 Jun 2026 05:10:01 -0700 Subject: [PATCH] fix(security): prevent rate-limit bypass via X-Forwarded-For header spoofing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VULNERABILITY: Both getClientIp() implementations (rate-limiter.js and middleware.js) blindly trusted the first value in the X-Forwarded-For header. Because HTTP clients fully control this header when there is no upstream proxy stripping it, an attacker can rotate it on every request to bypass all IP-based rate limiting. Affected endpoints (all rely on IP-based limiting): - POST /api/auth/send-sms — 5 req/min (OTP spam, SMS cost amplification) - POST /api/auth/verify-sms — 5 req/min (OTP brute-force) - POST /api/auth/backup-pin — 10 req/min via middleware (PIN brute-force) - All /api/* endpoints — 60 req/min via middleware Attack PoC: for i in {1..1000}; do curl -X POST /api/auth/send-sms \ -H "X-Forwarded-For: 1.2.3.$i" \ -d '{"phoneNumber":"+15551234567"}' done # Each request sees a different IP → rate limit never triggers. Fix: - Introduce TRUSTED_PROXY_COUNT env var (default 0). - When > 0, read the correct IP from the right-hand side of X-Forwarded-For (the proxy-appended, unforged position). - When 0 (direct internet, no trusted proxy), skip X-Forwarded-For entirely and rely only on X-Real-IP, which nginx/ALB typically sets from the real connection address after stripping any client-supplied value. - Add detailed inline documentation to guide operators. Operators behind Cloudflare / nginx should set TRUSTED_PROXY_COUNT=1 in their deployment environment. Severity: MEDIUM — bypasses brute-force / spam protections on auth endpoints --- src/lib/server/rate-limiter.js | 42 +++++++++++++++++++++++++++++++--- src/middleware.js | 18 +++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/lib/server/rate-limiter.js b/src/lib/server/rate-limiter.js index d5ee0ef..9cab62d 100644 --- a/src/lib/server/rate-limiter.js +++ b/src/lib/server/rate-limiter.js @@ -70,14 +70,50 @@ export const authRateLimiter = new RateLimiter({ maxRequests: 5, windowMs: 60 * export const apiRateLimiter = new RateLimiter({ maxRequests: 30, windowMs: 60 * 1000 }); // 30 req/min /** - * Extract client IP from a Next.js Request object + * Extract client IP from a Next.js Request object. + * + * SECURITY NOTE: X-Forwarded-For and X-Real-IP headers can be forged by the + * client unless the application sits behind a trusted reverse proxy that strips + * or overwrites these headers before forwarding. When no trusted proxy is in + * place, an attacker can trivially rotate the header value to bypass IP-based + * rate limiting. + * + * The function honours these headers (necessary when deployed behind a load + * balancer / CDN), but callers should be aware that in a direct-to-internet + * deployment the returned value cannot be fully trusted. Production deployments + * SHOULD: + * 1. Deploy behind a reverse proxy (nginx, Cloudflare, AWS ALB, …) that is + * configured to strip / overwrite X-Forwarded-For before it reaches this + * server, OR + * 2. Configure TRUSTED_PROXY_COUNT in the environment and only read that many + * hops from the right-hand side of the X-Forwarded-For list (the rightmost + * entry is appended by the last trusted proxy and cannot be spoofed). + * * @param {Request} request * @returns {string} */ export function getClientIp(request) { + const trustedProxyCount = parseInt(process.env.TRUSTED_PROXY_COUNT ?? '0', 10); + + if (trustedProxyCount > 0) { + // Take the Nth-from-right entry in X-Forwarded-For where N = trustedProxyCount. + // Each trusted hop appends one IP; the rightmost `trustedProxyCount` entries + // are injected by infrastructure we control and are reliable. + 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]; + } + } + } + + // Fallback: use X-Real-IP (set by nginx real_ip module after trusted proxy + // processing) or fall through to 'unknown'. When TRUSTED_PROXY_COUNT is 0 + // (default / direct internet deployment) we deliberately skip the potentially + // spoofed X-Forwarded-For header and rely on X-Real-IP only. return ( - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || - request.headers.get('x-real-ip') || + request.headers.get('x-real-ip') ?? 'unknown' ); } diff --git a/src/middleware.js b/src/middleware.js index ee40857..7bc6221 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -7,9 +7,23 @@ const apiRateLimiter = new RateLimiter({ maxRequests: 60, windowMs: 60 * 1000 }) const keyBackupRateLimiter = new RateLimiter({ maxRequests: 5, windowMs: 60 * 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-forwarded-for')?.split(',')[0]?.trim() || - request.headers.get('x-real-ip') || + request.headers.get('x-real-ip') ?? 'unknown' ); }