Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
45 changes: 45 additions & 0 deletions __tests__/request/ip-port.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use strict'

const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
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.

describe('req.ip with port in X-Forwarded-For', () => {
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'
Comment on lines +8 to +12
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.
assert.strictEqual(req.ip, '1.2.3.4')
})
})

describe('when XFF contains IPv6 with port', () => {
it('should strip the port and brackets', () => {
const req = request()
req.app.proxy = true
req.header['x-forwarded-for'] = '[::1]:8080, 127.0.0.1'
assert.strictEqual(req.ip, '::1')
})
})

describe('when XFF contains plain IPv4 (no port)', () => {
it('should return the address as-is', () => {
const req = request()
req.app.proxy = true
req.header['x-forwarded-for'] = '1.2.3.4, 5.6.7.8'
assert.strictEqual(req.ip, '1.2.3.4')
})
})

describe('when XFF contains plain IPv6 (no port)', () => {
it('should return the address as-is', () => {
const req = request()
req.app.proxy = true
req.header['x-forwarded-for'] = '::1, 127.0.0.1'
assert.strictEqual(req.ip, '::1')
})
})
})
33 changes: 31 additions & 2 deletions lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const sp = require('./search-params.js')
const typeis = require('type-is')
const fresh = require('fresh')
const only = require('./only.js')
const forwarded = require('forwarded')
const util = require('util')

const IP = Symbol('context#ip')
Expand All @@ -23,6 +24,28 @@ const IP = Symbol('context#ip')
* Prototype.
*/


/**
* Strip port from an IP address string.
* Handles IPv4 (e.g. "1.2.3.4:8080") and bracketed IPv6 (e.g. "[::1]:8080").
* @param {string} addr
* @returns {string}
* @private
*/
function stripPort(addr) {
if (!addr) return ''
if (addr.charCodeAt(0) === 91 /* [ */) {
const end = addr.indexOf(']')
return end > 1 ? addr.slice(1, end) : addr
}
// IPv4 with port has exactly one colon; IPv6 has multiple
const lastColon = addr.lastIndexOf(':')
if (lastColon > 0 && addr.indexOf(':') === lastColon) {
return addr.slice(0, lastColon)
}
return addr
}

module.exports = {

/**
Expand Down Expand Up @@ -462,7 +485,13 @@ module.exports = {

get ip () {
if (!this[IP]) {
this[IP] = this.ips[0] || this.socket.remoteAddress || ''
// 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] || ''
Comment on lines +487 to +492
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.
this[IP] = addr
}
return this[IP]
},
Expand Down Expand Up @@ -744,4 +773,4 @@ if (util.inspect.custom) {
*/
function splitCommaSeparatedValues (value, limit) {
return value.split(',', limit).map(v => v.trim())
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"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.
"fresh": "~0.5.2",
"http-assert": "^1.5.0",
"http-errors": "^2.0.0",
Expand All @@ -70,4 +71,4 @@
"lib"
],
"homepage": "https://koajs.com"
}
}
Loading