diff --git a/src/lib/detect.test.ts b/src/lib/detect.test.ts index 6aaab223b..b93722936 100644 --- a/src/lib/detect.test.ts +++ b/src/lib/detect.test.ts @@ -20,3 +20,26 @@ test('getIpAddress: Standard header', () => { test('getIpAddress: No header', () => { expect(getIpAddress(new Headers())).toEqual(undefined); }); + +test('getIpAddress: skips private/internal IP for the public client IP', () => { + delete process.env.CLIENT_IP_HEADER; + + expect( + getIpAddress( + new Headers({ + 'x-real-ip': '10.0.0.8', + 'x-forwarded-for': '79.127.237.104, 10.0.0.8', + }), + ), + ).toEqual('79.127.237.104'); +}); + +test('getIpAddress: custom x-forwarded-for header extracts the first IP', () => { + process.env.CLIENT_IP_HEADER = 'x-forwarded-for'; + + expect(getIpAddress(new Headers({ 'x-forwarded-for': '79.127.237.104, 10.0.0.8' }))).toEqual( + '79.127.237.104', + ); + + delete process.env.CLIENT_IP_HEADER; +}); diff --git a/src/lib/ip.ts b/src/lib/ip.ts index 075a481f5..558578256 100644 --- a/src/lib/ip.ts +++ b/src/lib/ip.ts @@ -58,31 +58,86 @@ function resolveIp(ip?: string | null) { } } -export function getIpAddress(headers: Headers) { - const customHeader = process.env.CLIENT_IP_HEADER; +/** + * Detect private/internal addresses (RFC1918, loopback, link-local, CGNAT, + * IPv6 unique-local) which can be injected by internal proxies or load + * balancers and should not be treated as the real client IP. + */ +function isPrivateIp(ip: string) { + try { + const range = ipaddr.parse(ip).range(); + + return ( + range === 'private' || + range === 'loopback' || + range === 'linkLocal' || + range === 'carrierGradeNat' || + range === 'uniqueLocal' + ); + } catch { + return false; + } +} - if (customHeader && headers.get(customHeader)) { - return resolveIp(headers.get(customHeader)); +/** + * Extract a single candidate IP from a header value, handling the + * comma-separated `x-forwarded-for` list and the `forwarded` syntax. + */ +function extractIp(header: string, value: string) { + if (header === 'x-forwarded-for') { + return value.split(',')?.[0]?.trim(); } - const header = IP_ADDRESS_HEADERS.find(name => headers.get(name)); - if (!header) { - return undefined; + if (header === 'forwarded') { + const match = value.match(/for=(\[?[0-9a-fA-F:.]+]?)/); + + return match ? match[1] : undefined; } - const ip = headers.get(header); + return value; +} - if (header === 'x-forwarded-for') { - return resolveIp(ip?.split(',')?.[0]?.trim()); +export function getIpAddress(headers: Headers) { + const customHeader = process.env.CLIENT_IP_HEADER; + + if (customHeader) { + const value = headers.get(customHeader); + + if (value) { + return resolveIp(extractIp(customHeader, value)); + } } - if (header === 'forwarded') { - const match = ip.match(/for=(\[?[0-9a-fA-F:.]+]?)/); + // Check candidate headers in priority order. Skip private/internal addresses, + // which can appear (e.g. in x-real-ip or forwarded) when a request passes + // through internal proxies or load balancers, and fall through to the next + // header that carries a public client IP. + let fallback: string | null | undefined; + + for (const name of IP_ADDRESS_HEADERS) { + const value = headers.get(name); + + if (!value) { + continue; + } - return match ? resolveIp(match[1]) : undefined; + const ip = resolveIp(extractIp(name, value)); + + if (!ip) { + continue; + } + + if (isPrivateIp(ip)) { + // Keep the first private match as a last resort, in case every candidate + // header only carries a private address. + fallback ??= ip; + continue; + } + + return ip; } - return resolveIp(ip); + return fallback; } export function stripPort(ip?: string | null) {