From 200f256394f05a793dff33c2807384443cc706da Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 15 Sep 2026 07:11:03 +0000 Subject: [PATCH] fix: prevent IPv4-mapped IPv6 trust subnets from matching all IPv4 An IPv4-mapped IPv6 trust subnet with a short prefix, such as `::ffff:10.0.0.0/8`, compiled with all-zero leading bits and matched every IPv4 address instead of the block it named. This trusted any unauthenticated client as a proxy at hop 0, so proxyaddr() (and req.ip/req.ips in Express) returned the attacker-controlled X-Forwarded-For value, defeating IP-based access control, rate limiting, geolocation and audit logging. Port the upstream proxy-addr 2.0.8 remediation: - canonicalize IPv4-mapped candidates to IPv4 up front, so a mapped address cannot bypass the cross-family guard via the same-family path - an IPv6 trust subnet only spans IPv4 when it is a genuine IPv4-mapped subnet whose prefix covers the ::ffff: marker (range >= 96) - a native IPv6 candidate can no longer match an IPv4-mapped subnet Ref: GHSA-jqcg-44mw-7w3h / CVE-2026-90711 --- index.js | 50 ++++++++++++++++++++++++++++++++++++++--------- test/base.test.js | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index c9346a3..b6ae18f 100644 --- a/index.js +++ b/index.js @@ -257,7 +257,14 @@ function trustMulti (subnets) { return function trust (addr) { if (!isip(addr)) return false - const ip = parseip(addr) + let ip = parseip(addr) + + if (ip.kind() === 'ipv6' && ip.isIPv4MappedAddress()) { + // Canonicalize IPv4-mapped candidates to IPv4 so a mapped address + // cannot bypass the cross-family guard via the same-family path + ip = ip.toIPv4Address() + } + let ipconv const kind = ip.kind() @@ -276,14 +283,23 @@ function trustMulti (subnets) { continue } + if (!subnetisipv4 && !(subnetrange >= 96 && subnetip.isIPv4MappedAddress())) { + // IPv6 subnet only spans IPv4 when it is a mapped subnet whose + // prefix covers the ::ffff: marker; otherwise it must not match IPv4 + continue + } + if (!ipconv) { - // Convert IP to match subnet IP kind - ipconv = subnetisipv4 - ? ip.toIPv4Address() - : ip.toIPv4MappedAddress() + // IPv4-mapped candidates were canonicalized to IPv4 above, so a + // cross-family IPv4 subnet is handled via the same-family path and + // this conversion only ever produces an IPv4-mapped address + ipconv = ip.toIPv4MappedAddress() } trusted = ipconv + } else if (kind === 'ipv6' && subnetip.isIPv4MappedAddress()) { + // A native IPv6 candidate cannot match an IPv4-mapped subnet + continue } if (trusted.match(subnetip, subnetrange)) { @@ -312,6 +328,13 @@ function trustSingle (subnet) { if (!isip(addr)) return false let ip = parseip(addr) + + if (ip.kind() === 'ipv6' && ip.isIPv4MappedAddress()) { + // Canonicalize IPv4-mapped candidates to IPv4 so a mapped address + // cannot bypass the cross-family guard via the same-family path + ip = ip.toIPv4Address() + } + const kind = ip.kind() if (kind !== subnetkind) { @@ -320,10 +343,19 @@ function trustSingle (subnet) { return false } - // Convert IP to match subnet IP kind - ip = subnetisipv4 - ? ip.toIPv4Address() - : ip.toIPv4MappedAddress() + if (!subnetisipv4 && !(subnetrange >= 96 && subnetip.isIPv4MappedAddress())) { + // IPv6 subnet only spans IPv4 when it is a mapped subnet whose + // prefix covers the ::ffff: marker; otherwise it must not match IPv4 + return false + } + + // IPv4-mapped candidates were canonicalized to IPv4 above, so a + // cross-family IPv4 subnet is handled via the same-family path and + // this conversion only ever produces an IPv4-mapped address + ip = ip.toIPv4MappedAddress() + } else if (kind === 'ipv6' && subnetip.isIPv4MappedAddress()) { + // A native IPv6 candidate cannot match an IPv4-mapped subnet + return false } return ip.match(subnetip, subnetrange) diff --git a/test/base.test.js b/test/base.test.js index e928b62..f0c7d6c 100644 --- a/test/base.test.js +++ b/test/base.test.js @@ -294,6 +294,41 @@ test('when IPv4-mapped IPv6 addresses should match CIDR notation for IPv4-mapped t.assert.strictEqual(proxyaddr(req, ['::ffff:a00:2/122', '127.0.0.1']), '10.0.0.200') }) +test('when IPv4-mapped IPv6 trust subnet has a short prefix it should not trust arbitrary IPv4', function (t) { + const req = createReq('1.1.1.1', { + 'x-forwarded-for': '6.6.6.6' + }) + t.assert.strictEqual(proxyaddr(req, '::ffff:10.0.0.0/8'), '1.1.1.1') +}) + +test('when IPv6 trust subnet has zero leading bits it should not trust arbitrary IPv4', function (t) { + const req = createReq('1.1.1.1', { + 'x-forwarded-for': '6.6.6.6' + }) + t.assert.strictEqual(proxyaddr(req, '::/1'), '1.1.1.1') +}) + +test('when IPv4-mapped IPv6 trust subnet is used a native IPv6 address should not match', function (t) { + const req = createReq('2001:db8::1', { + 'x-forwarded-for': '6.6.6.6' + }) + t.assert.strictEqual(proxyaddr(req, '::ffff:10.0.0.0/104'), '2001:db8::1') +}) + +test('when IPv4-mapped IPv6 trust subnet in a list has a short prefix it should not trust arbitrary IPv4', function (t) { + const req = createReq('1.1.1.1', { + 'x-forwarded-for': '6.6.6.6' + }) + t.assert.strictEqual(proxyaddr(req, ['::ffff:10.0.0.0/8', '127.0.0.1']), '1.1.1.1') +}) + +test('when IPv4-mapped IPv6 trust subnet in a list is used a native IPv6 address should not match', function (t) { + const req = createReq('2001:db8::1', { + 'x-forwarded-for': '6.6.6.6' + }) + t.assert.strictEqual(proxyaddr(req, ['::ffff:10.0.0.0/104', 'fe80::/125']), '2001:db8::1') +}) + test('when given predefined names should accept single pre-defined name', function (t) { const req = createReq('fe80::1', { 'x-forwarded-for': '2002:c000:203::1, fe80::2'