From 17c3756a31dcdf129d1b2c0e35bd734348260a29 Mon Sep 17 00:00:00 2001 From: Kristofor Carle Date: Mon, 8 Jun 2026 15:47:05 -0400 Subject: [PATCH] Skip private/internal IPs when detecting the client IP from headers When a request passes through internal proxies or load balancers (e.g. Nomad/fabio behind a cloud load balancer), a header such as `x-real-ip` or `forwarded` can carry an internal address (10.x, 172.16-31.x, 192.168.x, loopback, link-local, CGNAT, IPv6 ULA) while the real client IP is present in another header like `x-forwarded-for`. Because the previous logic picked the first header that was merely present, it could return the internal IP. Iterate the candidate headers in priority order, normalize each value, and skip private/internal addresses, falling through to the next header that carries a public client IP. If every candidate is private, the first one is still returned as a last resort so behaviour is unchanged for local setups. Also route the `CLIENT_IP_HEADER` path through the same extraction so a custom header pointing at a multi-IP `x-forwarded-for` list resolves the first entry instead of the raw list. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/detect.test.ts | 23 ++++++++++++ src/lib/ip.ts | 83 +++++++++++++++++++++++++++++++++++------- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/lib/detect.test.ts b/src/lib/detect.test.ts index 6aaab223bb..b937229365 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 075a481f51..5585782568 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) {