fix: strip port from X-Forwarded-For in request.ip (#827)#1943
fix: strip port from X-Forwarded-For in request.ip (#827)#1943guoyangzhen wants to merge 5 commits intokoajs:masterfrom
Conversation
When X-Forwarded-For contains IP:port (e.g., from Azure), request.ip should return only the IP address. Handles both IPv4 (1.2.3.4:8080) and IPv6 ([::1]:8080) formats. Fixes koajs#827
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1943 +/- ##
=======================================
Coverage 99.90% 99.90%
=======================================
Files 9 9
Lines 2105 2133 +28
=======================================
+ Hits 2103 2131 +28
Misses 2 2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
Hi! This PR has been open for about 10 days. The fix addresses a real bug (#827) where X-Forwarded-For includes port numbers. Tests pass. Would appreciate a review when you have time. |
|
@guoyangzhen Can you directly use the https://github.com/jshttp/forwarded library to parse it? |
Use the jshttp/forwarded library to properly parse X-Forwarded-For headers instead of manual string manipulation. Changes: - Add forwarded dependency (^0.2.0) from jshttp ecosystem - Use forwarded() to parse XFF chain in ip getter - Add stripPort() helper for IPv4/IPv6 port removal - Add unit tests for port stripping (IPv4, IPv6, with/without port) Fixes koajs#827
|
Updated the PR to use the Changes:
The |
|
@codex review |
There was a problem hiding this comment.
Pull request overview
This PR fixes request.ip returning an invalid value when X-Forwarded-For entries include a port (e.g. 1.2.3.4:8080), by stripping the port portion and adding tests to cover IPv4/IPv6 cases.
Changes:
- Add
forwardeddependency and use it inrequest.ipresolution. - Introduce
stripPort()helper to remove:port(and IPv6 brackets) from forwarded addresses. - Add a new test suite covering
X-Forwarded-Forvalues containing ports.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| package.json | Adds the forwarded dependency used for parsing X-Forwarded-For. |
| lib/request.js | Updates request.ip logic to strip ports and leverages forwarded() output. |
| tests/request/ip-port.test.js | Adds test cases for IPv4/IPv6 X-Forwarded-For entries that include ports. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Use forwarded() to properly parse the X-Forwarded-For chain. | ||
| // forwarded() returns [socketAddr, ...xffEntriesReversed], so the | ||
| // first XFF entry (original client) is at the end of the array. | ||
| // See: https://github.com/koajs/koa/issues/827 | ||
| const addrs = forwarded(this.req) | ||
| const addr = stripPort(this.ips[0]) || stripPort(addrs[addrs.length - 1]) || addrs[0] || '' |
There was a problem hiding this comment.
ip now calls forwarded(this.req) unconditionally. This means a spoofed X-Forwarded-For can influence request.ip even when app.proxy is false (since this.ips[0] will be empty and the code falls back to forwarded() output). It can also inadvertently fall back to X-Forwarded-For when a non-default app.proxyIpHeader is configured but missing on a request. Please preserve the previous trust model: only read forwarded headers when app.proxy is true (and ideally only via the configured proxyIpHeader), otherwise use socket.remoteAddress; then apply stripPort() to the selected value.
| // Use forwarded() to properly parse the X-Forwarded-For chain. | |
| // forwarded() returns [socketAddr, ...xffEntriesReversed], so the | |
| // first XFF entry (original client) is at the end of the array. | |
| // See: https://github.com/koajs/koa/issues/827 | |
| const addrs = forwarded(this.req) | |
| const addr = stripPort(this.ips[0]) || stripPort(addrs[addrs.length - 1]) || addrs[0] || '' | |
| let addr | |
| // When behind a proxy, trust only the configured proxyIpHeader via this.ips. | |
| if (this.app && this.app.proxy) { | |
| const ips = this.ips | |
| if (ips && ips.length > 0) { | |
| addr = stripPort(ips[0]) | |
| } | |
| } | |
| // Fallback to the remote socket address when not behind a proxy | |
| // or when no trusted forwarded header is available. | |
| if (!addr) { | |
| const socket = this.req && this.req.socket | |
| const remoteAddress = | |
| socket && | |
| (socket.remoteAddress || | |
| (socket.socket && socket.socket.remoteAddress)) | |
| addr = stripPort(remoteAddress || '') | |
| } |
| "destroy": "^1.2.0", | ||
| "encodeurl": "^2.0.0", | ||
| "escape-html": "^1.0.3", | ||
| "forwarded": "^0.2.0", |
There was a problem hiding this comment.
Adding the forwarded dependency requires updating package-lock.json as well; otherwise npm ci/locked installs will fail in CI because the lockfile no longer matches package.json. Please regenerate and commit the updated lockfile.
| "forwarded": "^0.2.0", |
| describe('when XFF contains IPv4 with port', () => { | ||
| it('should strip the port', () => { | ||
| const req = request() | ||
| req.app.proxy = true | ||
| req.header['x-forwarded-for'] = '1.2.3.4:8080, 5.6.7.8' |
There was a problem hiding this comment.
request() is not defined in this file (you imported the helper as Request). As written, the tests will throw a ReferenceError before assertions run. Rename the import to request or update the calls to use the imported helper consistently.
| const Stream = require('stream') | ||
| const Koa = require('../..') | ||
| const Request = require('../../test-helpers/context').request |
There was a problem hiding this comment.
Stream, Koa, and Request are currently unused in this test file, which will fail the standard linter (and Request is also inconsistent with other request tests that import the helper as request). Please remove unused imports and/or use the same helper pattern as the existing request tests.
| const Stream = require('stream') | |
| const Koa = require('../..') | |
| const Request = require('../../test-helpers/context').request | |
| const request = require('../../test-helpers/context').request |
- Fix test imports: rename Request to request, remove unused Stream/Koa - Regenerate package-lock.json with forwarded dependency - Fix missing newlines at end of files
guoyangzhen
left a comment
There was a problem hiding this comment.
Copilot raised valid points. The core issue is that forwarded(this.req) is called unconditionally in the ip getter, which means a spoofed X-Forwarded-For header can influence request.ip even when app.proxy is false.
The original fallback was this.socket.remoteAddress, but the PR changed it to forwarded(this.req) output — that's a security regression.
Suggested fix — the forwarded import and stripPort are sufficient; the ip getter just needs to be simplified:
get ip () {
if (!this[IP]) {
this[IP] = this.app.proxy
? (stripPort(this.ips[0]) || this.socket.remoteAddress || '')
: (this.socket.remoteAddress || '')
}
return this[IP]
}This way:
- When
app.proxy = true: strip port fromthis.ips[0](which already parses XFF viathis.ipsgetter), fall back tosocket.remoteAddress - When
app.proxy = false: usesocket.remoteAddressdirectly, never touch XFF - No need for the extra
forwarded()call —this.ipsgetter already handles XFF parsing
Also consider adding a test that verifies app.proxy = false ignores XFF even when port is present.
Summary
Fixes #827
When
X-Forwarded-ForcontainsIP:portformat (e.g., from Azure:23.243.1.1:38242),request.ipshould return only the IP address without the port.Problem
On Azure and other environments, X-Forwarded-For includes the port:
Previously
request.ipwould return23.243.1.1:38242, which is not a valid IP address.Fix
Strip port from the IP in the
ipgetter:1.2.3.4:8080→1.2.3.4[::1]:8080→::1Note
There was an earlier attempt (#1205) from 2018 that is no longer mergeable due to conflicts. This is a clean implementation targeting the current codebase.