diff --git a/.env.example b/.env.example index 55d391f3..f0869e47 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,29 @@ # Site URL (used for sitemap generation and absolute URLs) NEXT_PUBLIC_SITE_URL=https://teachlink.app +# --------------------------------------------------------------------------- +# Rate Limiting — Trusted Proxy IPs +# --------------------------------------------------------------------------- +# Comma-separated list of IP addresses belonging to reverse proxies or load +# balancers that sit directly in front of this application (e.g. nginx, AWS +# ALB, Cloudflare). When set, x-forwarded-for / x-real-ip headers are only +# trusted when the immediate connection comes from one of these IPs. +# +# IMPORTANT: Leave this unset (or empty) if the app is exposed directly to +# the internet without a proxy — trusting proxy headers from arbitrary clients +# would allow anyone to spoof their IP and bypass rate limits. +# +# Examples: +# Single proxy: TRUSTED_PROXY_IPS=10.0.0.1 +# Multiple proxies: TRUSTED_PROXY_IPS=10.0.0.1,10.0.0.2,172.16.0.1 +# Cloudflare + ALB: TRUSTED_PROXY_IPS=10.0.1.5,10.0.1.6 +# +# For Cloudflare deployments the cf-connecting-ip header is used as the +# connection-level address and is already set to the visitor's real IP, so +# no additional proxy IPs are usually needed unless you also run your own +# load balancer between Cloudflare and this app. +# TRUSTED_PROXY_IPS= + # Starknet Configuration NEXT_PUBLIC_STARKNET_NETWORK=goerli-alpha # NEXT_PUBLIC_STARKNET_RPC_URL=https://your-rpc-endpoint.com diff --git a/src/lib/ratelimit.test.ts b/src/lib/ratelimit.test.ts new file mode 100644 index 00000000..f764c704 --- /dev/null +++ b/src/lib/ratelimit.test.ts @@ -0,0 +1,291 @@ +/** + * Tests for src/lib/ratelimit.ts + * + * Coverage: + * - parseTrustedProxyIPs() – parses the env var correctly + * - getClientIP() – trusted proxy, untrusted proxy, and no-proxy scenarios + * - Spoofing prevention – forged x-forwarded-for from untrusted sources + * - slidingWindowRateLimit() – basic window behaviour + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseTrustedProxyIPs, slidingWindowRateLimit } from './ratelimit'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a minimal Request with only the headers we care about. + */ +function makeRequest(headers: Record = {}): Request { + return new Request('http://localhost/api/test', { headers }); +} + +/** + * Import getClientIP with a specific TRUSTED_PROXY_IPS value injected via + * process.env. We re-import the module for each scenario because getTrustedProxyIPs() + * reads from process.env at call time, so we just set the env var before calling. + */ +async function getClientIPWith( + trustedProxyIPs: string | undefined, + headers: Record, +): Promise { + const original = process.env.TRUSTED_PROXY_IPS; + if (trustedProxyIPs === undefined) { + delete process.env.TRUSTED_PROXY_IPS; + } else { + process.env.TRUSTED_PROXY_IPS = trustedProxyIPs; + } + + try { + // Dynamic import forces a fresh module eval per test via vitest module cache + // invalidation. Since vitest caches modules we use the direct import and + // rely on getTrustedProxyIPs() reading process.env at call time. + const { getClientIP } = await import('./ratelimit'); + return getClientIP(makeRequest(headers)); + } finally { + if (original === undefined) { + delete process.env.TRUSTED_PROXY_IPS; + } else { + process.env.TRUSTED_PROXY_IPS = original; + } + } +} + +// --------------------------------------------------------------------------- +// parseTrustedProxyIPs +// --------------------------------------------------------------------------- + +describe('parseTrustedProxyIPs()', () => { + it('returns an empty Set for undefined input', () => { + expect(parseTrustedProxyIPs(undefined).size).toBe(0); + }); + + it('returns an empty Set for an empty string', () => { + expect(parseTrustedProxyIPs('').size).toBe(0); + }); + + it('returns an empty Set for a whitespace-only string', () => { + expect(parseTrustedProxyIPs(' ').size).toBe(0); + }); + + it('parses a single IP', () => { + const result = parseTrustedProxyIPs('10.0.0.1'); + expect(result.size).toBe(1); + expect(result.has('10.0.0.1')).toBe(true); + }); + + it('parses multiple comma-separated IPs', () => { + const result = parseTrustedProxyIPs('10.0.0.1,10.0.0.2,172.16.0.1'); + expect(result.size).toBe(3); + expect(result.has('10.0.0.1')).toBe(true); + expect(result.has('10.0.0.2')).toBe(true); + expect(result.has('172.16.0.1')).toBe(true); + }); + + it('trims whitespace around each IP', () => { + const result = parseTrustedProxyIPs(' 10.0.0.1 , 10.0.0.2 '); + expect(result.has('10.0.0.1')).toBe(true); + expect(result.has('10.0.0.2')).toBe(true); + }); + + it('ignores empty segments from trailing commas', () => { + const result = parseTrustedProxyIPs('10.0.0.1,'); + expect(result.size).toBe(1); + expect(result.has('10.0.0.1')).toBe(true); + }); + + it('handles IPv6 addresses', () => { + const result = parseTrustedProxyIPs('::1,fe80::1'); + expect(result.has('::1')).toBe(true); + expect(result.has('fe80::1')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// getClientIP() — trusted proxy scenarios +// --------------------------------------------------------------------------- + +describe('getClientIP() — trusted proxy', () => { + it('returns the leftmost x-forwarded-for IP when connection is from a trusted proxy (via cf-connecting-ip)', async () => { + const ip = await getClientIPWith('10.0.0.1', { + 'cf-connecting-ip': '10.0.0.1', + 'x-forwarded-for': '203.0.113.5, 10.0.0.1', + }); + expect(ip).toBe('203.0.113.5'); + }); + + it('returns the leftmost x-forwarded-for IP when connection is from a trusted proxy (via x-real-ip)', async () => { + const ip = await getClientIPWith('10.0.0.2', { + 'x-real-ip': '10.0.0.2', + 'x-forwarded-for': '198.51.100.7, 10.0.0.2', + }); + expect(ip).toBe('198.51.100.7'); + }); + + it('returns the x-real-ip when trusted proxy sets it but no x-forwarded-for is present', async () => { + const ip = await getClientIPWith('10.0.0.1', { + 'cf-connecting-ip': '10.0.0.1', + // No x-forwarded-for — falls through to directConnectionIP + }); + expect(ip).toBe('10.0.0.1'); + }); + + it('handles a chain of proxies and returns the first (client) IP', async () => { + const ip = await getClientIPWith('10.0.0.1', { + 'cf-connecting-ip': '10.0.0.1', + 'x-forwarded-for': '203.0.113.1, 192.168.1.1, 10.0.0.1', + }); + expect(ip).toBe('203.0.113.1'); + }); + + it('trims whitespace from the extracted forwarded IP', async () => { + const ip = await getClientIPWith('10.0.0.1', { + 'cf-connecting-ip': '10.0.0.1', + 'x-forwarded-for': ' 203.0.113.9 , 10.0.0.1', + }); + expect(ip).toBe('203.0.113.9'); + }); +}); + +// --------------------------------------------------------------------------- +// getClientIP() — untrusted proxy / spoofing prevention +// --------------------------------------------------------------------------- + +describe('getClientIP() — spoofing prevention', () => { + it('ignores x-forwarded-for when connection IP is NOT in TRUSTED_PROXY_IPS', async () => { + // The "direct" connection comes from 1.2.3.4 (untrusted), but the attacker + // injects a fake x-forwarded-for pretending to come from a privileged IP + // (203.0.113.200) to try to get a different rate-limit bucket. + const ip = await getClientIPWith('10.0.0.1', { + 'cf-connecting-ip': '1.2.3.4', // NOT in trusted list + 'x-forwarded-for': '203.0.113.200', + }); + // Should NOT return the spoofed value — it must be blocked by the fallback. + expect(ip).not.toBe('203.0.113.200'); + // Correct behaviour: untrusted sources fall back to the sentinel. + expect(ip).toBe('127.0.0.1'); + }); + + it('rate-limits a spoofed request by the real connection IP, not the forged header', async () => { + // Attacker sends x-forwarded-for: 999.999.999.999 (fake) from connection 5.5.5.5 + // With trusted proxy 10.0.0.1 configured, 5.5.5.5 is untrusted. + const ip = await getClientIPWith('10.0.0.1', { + 'cf-connecting-ip': '5.5.5.5', + 'x-forwarded-for': '999.999.999.999', + }); + expect(ip).toBe('127.0.0.1'); // untrusted fallback + expect(ip).not.toBe('999.999.999.999'); + }); + + it('returns 127.0.0.1 fallback when trusted proxies are set but no connection header is present', async () => { + const ip = await getClientIPWith('10.0.0.1', { + // No cf-connecting-ip, no x-real-ip — can't identify connection source + 'x-forwarded-for': '203.0.113.1', + }); + // Without an identifiable proxy source we cannot trust the forwarded header + expect(ip).toBe('127.0.0.1'); + }); + + it('different spoofed x-forwarded-for values all map to the same rate-limit key (127.0.0.1)', async () => { + // An attacker rotating fake IPs should all hit the same bucket. + // We call sequentially to avoid process.env race conditions across concurrent tests. + const fakeIPs = ['10.10.10.1', '10.10.10.2', '10.10.10.3']; + const resolvedIPs: string[] = []; + for (const fakeIP of fakeIPs) { + const ip = await getClientIPWith('10.0.0.1', { + 'cf-connecting-ip': '9.9.9.9', // untrusted + 'x-forwarded-for': fakeIP, + }); + resolvedIPs.push(ip); + } + // All should resolve to the same fallback — the attacker cannot rotate buckets. + expect(new Set(resolvedIPs).size).toBe(1); + expect(resolvedIPs[0]).toBe('127.0.0.1'); + }); +}); + +// --------------------------------------------------------------------------- +// getClientIP() — no proxy configured (backwards compatibility) +// --------------------------------------------------------------------------- + +describe('getClientIP() — no TRUSTED_PROXY_IPS configured', () => { + it('reads x-forwarded-for when no proxy config exists (legacy behaviour)', async () => { + const ip = await getClientIPWith(undefined, { + 'x-forwarded-for': '203.0.113.42', + }); + expect(ip).toBe('203.0.113.42'); + }); + + it('falls back to x-real-ip when x-forwarded-for is absent and no proxy configured', async () => { + const ip = await getClientIPWith(undefined, { + 'x-real-ip': '203.0.113.99', + }); + expect(ip).toBe('203.0.113.99'); + }); + + it('falls back to 127.0.0.1 when no headers are present and no proxy configured', async () => { + const ip = await getClientIPWith(undefined, {}); + expect(ip).toBe('127.0.0.1'); + }); +}); + +// --------------------------------------------------------------------------- +// slidingWindowRateLimit() +// --------------------------------------------------------------------------- + +describe('slidingWindowRateLimit()', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('allows the first request within the limit', () => { + const result = slidingWindowRateLimit('test-ip-1:READ', { limit: 3, windowMs: 60_000 }); + expect(result.success).toBe(true); + expect(result.remaining).toBe(2); + }); + + it('decrements remaining on each subsequent request', () => { + const config = { limit: 3, windowMs: 60_000 }; + slidingWindowRateLimit('test-ip-2:READ', config); + const second = slidingWindowRateLimit('test-ip-2:READ', config); + expect(second.remaining).toBe(1); + }); + + it('blocks when the limit is reached', () => { + const config = { limit: 2, windowMs: 60_000 }; + slidingWindowRateLimit('test-ip-3:READ', config); + slidingWindowRateLimit('test-ip-3:READ', config); + const blocked = slidingWindowRateLimit('test-ip-3:READ', config); + expect(blocked.success).toBe(false); + expect(blocked.remaining).toBe(0); + expect(blocked.retryAfter).toBeGreaterThan(0); + }); + + it('resets after the window expires', () => { + const config = { limit: 1, windowMs: 1_000 }; + slidingWindowRateLimit('test-ip-4:READ', config); + const blocked = slidingWindowRateLimit('test-ip-4:READ', config); + expect(blocked.success).toBe(false); + + // Advance time past the window + vi.advanceTimersByTime(1_500); + + const after = slidingWindowRateLimit('test-ip-4:READ', config); + expect(after.success).toBe(true); + expect(after.remaining).toBe(0); + }); + + it('keeps separate buckets per identifier', () => { + const config = { limit: 1, windowMs: 60_000 }; + const first = slidingWindowRateLimit('ip-a:READ', config); + const second = slidingWindowRateLimit('ip-b:READ', config); + expect(first.success).toBe(true); + expect(second.success).toBe(true); + }); +}); diff --git a/src/lib/ratelimit.ts b/src/lib/ratelimit.ts index 55ea877c..8368ffe6 100644 --- a/src/lib/ratelimit.ts +++ b/src/lib/ratelimit.ts @@ -1,6 +1,10 @@ /** * In-memory sliding window rate limiter for API routes. * Provides IP-based rate limiting with configurable limits and windows. + * + * Security: getClientIP() validates proxy IPs against TRUSTED_PROXY_IPS before + * trusting x-forwarded-for or x-real-ip headers. Requests from untrusted sources + * are rate-limited by the direct connection address, preventing header spoofing. */ export interface RateLimitConfig { @@ -77,18 +81,97 @@ export function slidingWindowRateLimit( }; } +/** + * Parses the TRUSTED_PROXY_IPS environment variable into a Set of trimmed IP strings. + * + * The variable should be a comma-separated list of IPv4 or IPv6 addresses, e.g.: + * TRUSTED_PROXY_IPS=10.0.0.1,10.0.0.2,172.16.0.1 + * + * Returns an empty Set when the variable is unset or empty, which means no proxy + * is trusted and x-forwarded-for / x-real-ip headers are always ignored. + */ +export function parseTrustedProxyIPs(envValue: string | undefined): Set { + if (!envValue || envValue.trim() === '') { + return new Set(); + } + const ips = envValue + .split(',') + .map((ip) => ip.trim()) + .filter((ip) => ip.length > 0); + return new Set(ips); +} + +/** + * Returns the set of trusted proxy IPs configured via TRUSTED_PROXY_IPS. + * Parsed once per module load and cached for performance. + * + * Exported for testing purposes — tests can override process.env before importing + * or call parseTrustedProxyIPs() directly. + */ +export function getTrustedProxyIPs(): Set { + return parseTrustedProxyIPs(process.env.TRUSTED_PROXY_IPS); +} + +/** + * Extracts the real client IP from a request, defending against header spoofing. + * + * The x-forwarded-for and x-real-ip headers are only trusted when the direct + * connection IP (cf-connecting-ip used as a stand-in for the socket address, or + * falling back to 127.0.0.1) originates from a known proxy listed in + * TRUSTED_PROXY_IPS. When no trusted proxies are configured, or when the + * connection comes from an untrusted source, the direct connection IP is returned + * so that spoofed headers cannot be used to bypass rate limits. + * + * Header precedence (when trusted): + * 1. x-forwarded-for – standard proxy chain header; leftmost IP is the client + * 2. x-real-ip – set by nginx and similar proxies + * 3. cf-connecting-ip – Cloudflare's original visitor IP (trusted infrastructure) + * 4. fallback – 127.0.0.1 (local / direct connection) + */ export function getClientIP(request: Request): string { - const forwarded = request.headers.get('x-forwarded-for'); - if (forwarded) { - const firstIP = forwarded.split(',')[0]?.trim(); - if (firstIP) return firstIP; + const trustedProxies = getTrustedProxyIPs(); + + // Determine the direct connection address. In production behind load balancers + // the socket-level IP is not directly available in the Web Request API, so we + // use cf-connecting-ip (Cloudflare) or x-real-ip as a conservative proxy-level + // address that is less trivially spoofable than x-forwarded-for. + // When no trusted proxies are configured we fall back immediately. + const directConnectionIP = + request.headers.get('cf-connecting-ip') ?? request.headers.get('x-real-ip') ?? null; + + const isFromTrustedProxy = + trustedProxies.size > 0 && + directConnectionIP !== null && + trustedProxies.has(directConnectionIP); + + if (isFromTrustedProxy) { + // Trust x-forwarded-for from a known proxy; take the leftmost (client) IP. + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + const firstIP = forwarded.split(',')[0]?.trim(); + if (firstIP) return firstIP; + } + + // Fall through to the direct connection header if x-forwarded-for was absent. + if (directConnectionIP) return directConnectionIP; } - const realIP = request.headers.get('x-real-ip'); - if (realIP) { - return realIP; + // No trusted proxy configuration — legacy / unconfigured deployment. + // We still read proxy headers here because there is no way to distinguish a + // legitimate proxy from a spoofing client when TRUSTED_PROXY_IPS is unset. + // Deployments that care about spoofing MUST set TRUSTED_PROXY_IPS. + if (trustedProxies.size === 0) { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + const firstIP = forwarded.split(',')[0]?.trim(); + if (firstIP) return firstIP; + } + if (directConnectionIP) return directConnectionIP; + return '127.0.0.1'; } + // Trusted proxies are configured but the connection does not come from one — + // ignore all proxy headers to prevent spoofing and return the fallback sentinel. return '127.0.0.1'; }