Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/lib/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
83 changes: 69 additions & 14 deletions src/lib/ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +91 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isPrivateIp bypassed for bracketed IPv6 in Forwarded header

The extractIp regex /for=(\[?[0-9a-fA-F:.]+]?)/ can capture the brackets as part of the match, e.g. Forwarded: for=[::1] yields [::1] as the captured group. ipaddr.parse('[::1]') throws (brackets are not valid in the parser's notation), so isPrivateIp silently returns false, and the IPv6 loopback [::1] bypasses the private-IP guard and is returned as the client address. Stripping brackets before calling isPrivateIp (and before the resolveIp call) would close this gap — the plain form ::1 parses correctly and .range() returns 'loopback'.

}

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));
}
}
Comment on lines +103 to 109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CLIENT_IP_HEADER path skips private-IP check

When CLIENT_IP_HEADER is set to a header that contains a private address (e.g. CLIENT_IP_HEADER=x-real-ip and x-real-ip: 10.0.0.8), the code returns that private IP immediately without applying the isPrivateIp filter or falling through to other headers. This is a deliberate design choice (the user explicitly chose that header), but it means the custom-header path can still return internal addresses that break geolocation. A comment noting this intentional behavior would help future readers understand why the private-IP logic is omitted here.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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) {
Expand Down