-
Notifications
You must be signed in to change notification settings - Fork 7.3k
Skip private/internal IPs when detecting the client IP from headers #4329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)); | ||
| } | ||
| } | ||
|
Comment on lines
+103
to
109
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isPrivateIpbypassed for bracketed IPv6 inForwardedheaderThe
extractIpregex/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), soisPrivateIpsilently returnsfalse, and the IPv6 loopback[::1]bypasses the private-IP guard and is returned as the client address. Stripping brackets before callingisPrivateIp(and before theresolveIpcall) would close this gap — the plain form::1parses correctly and.range()returns'loopback'.