Skip to content

fix: strip port from X-Forwarded-For in request.ip (#827)#1943

Open
guoyangzhen wants to merge 5 commits intokoajs:masterfrom
guoyangzhen:fix/x-forwarded-for-port
Open

fix: strip port from X-Forwarded-For in request.ip (#827)#1943
guoyangzhen wants to merge 5 commits intokoajs:masterfrom
guoyangzhen:fix/x-forwarded-for-port

Conversation

@guoyangzhen
Copy link
Copy Markdown

Summary

Fixes #827

When X-Forwarded-For contains IP:port format (e.g., from Azure: 23.243.1.1:38242), request.ip should return only the IP address without the port.

Problem

On Azure and other environments, X-Forwarded-For includes the port:

'x-forwarded-for': '23.243.1.1:38242'

Previously request.ip would return 23.243.1.1:38242, which is not a valid IP address.

Fix

Strip port from the IP in the ip getter:

  • IPv4 with port: 1.2.3.4:80801.2.3.4
  • IPv6 with port: [::1]:8080::1
  • Plain IPv4/IPv6 (no port): unchanged

Note

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.

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
Copy link
Copy Markdown

codecov Bot commented Mar 18, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.90%. Comparing base (e0ba8ef) to head (3bb63fe).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@guoyangzhen
Copy link
Copy Markdown
Author

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.

@fengmk2
Copy link
Copy Markdown
Member

fengmk2 commented Mar 28, 2026

@guoyangzhen Can you directly use the https://github.com/jshttp/forwarded library to parse it?
And add a unit test to verify that the port is removed.

@fengmk2 fengmk2 self-assigned this Mar 28, 2026
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
@guoyangzhen
Copy link
Copy Markdown
Author

Updated the PR to use the forwarded library as requested.

Changes:

  • Added forwarded (^0.2.0) from the jshttp ecosystem as a dependency
  • Replaced manual string parsing with forwarded(this.req) to properly parse the X-Forwarded-For chain
  • Added a stripPort() helper that handles both IPv4 (1.2.3.4:8080) and bracketed IPv6 ([::1]:8080)
  • Added unit tests for port stripping with both IPv4 and IPv6 addresses

The forwarded library returns \[socketAddr, ...xffEntriesReversed\], so the original client IP (last in the XFF chain) is at addrs[addrs.length - 1]. We also strip the port from this.ips[0]\] as a fallback for custom proxyIpHeader` configurations.

@fengmk2 fengmk2 requested a review from Copilot April 1, 2026 05:53
@fengmk2
Copy link
Copy Markdown
Member

fengmk2 commented Apr 1, 2026

@codex review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 forwarded dependency and use it in request.ip resolution.
  • Introduce stripPort() helper to remove :port (and IPv6 brackets) from forwarded addresses.
  • Add a new test suite covering X-Forwarded-For values 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.

Comment thread lib/request.js
Comment on lines +488 to +493
// 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] || ''
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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 || '')
}

Copilot uses AI. Check for mistakes.
Comment thread package.json
"destroy": "^1.2.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"forwarded": "^0.2.0",
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
"forwarded": "^0.2.0",

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +14
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'
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread __tests__/request/ip-port.test.js Outdated
Comment on lines +5 to +7
const Stream = require('stream')
const Koa = require('../..')
const Request = require('../../test-helpers/context').request
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
const Stream = require('stream')
const Koa = require('../..')
const Request = require('../../test-helpers/context').request
const request = require('../../test-helpers/context').request

Copilot uses AI. Check for mistakes.
guoyangzhen and others added 2 commits April 1, 2026 21:15
- 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
Copy link
Copy Markdown
Author

@guoyangzhen guoyangzhen left a comment

Choose a reason for hiding this comment

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

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:

  1. When app.proxy = true: strip port from this.ips[0] (which already parses XFF via this.ips getter), fall back to socket.remoteAddress
  2. When app.proxy = false: use socket.remoteAddress directly, never touch XFF
  3. No need for the extra forwarded() call — this.ips getter already handles XFF parsing

Also consider adding a test that verifies app.proxy = false ignores XFF even when port is present.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

request.ip via x-forwarded-for should strip out ports

3 participants