From efc7d81f305f6071dae21c5c489dd028b8792ca3 Mon Sep 17 00:00:00 2001 From: TilakPatel Date: Tue, 21 Jul 2026 12:07:54 +0530 Subject: [PATCH 1/2] fix: normalize bare-IP restrictedAddresses entries against IPv6 embeddings isAddressRestricted() applied IPv6-embedding normalization (IPv4-mapped, IPv4-compatible, NAT64, IPv4-translated, 6to4) only to entries written in CIDR notation. A bare exact-match entry (e.g. '169.254.169.254') was checked via string equality alone, so any differently-encoded literal of that same address bypassed it -- even with unrelated CIDR entries present elsewhere in the same list. Promote every entry that parses as a valid IP -- CIDR or bare -- into the same restrictedCidrs list (bare IPv4 as /32, bare IPv6 as /128), so both forms get matched against the normalized address. Hostname entries (e.g. 'internal.corp') are unaffected -- they fail IP parsing and stay on the exact-match branch only. Follow-up to #1548. --- lib/requester/core.js | 43 +++++++++++++++++++------ test/unit/requester-core.test.js | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/lib/requester/core.js b/lib/requester/core.js index 46abe2842..8b6522856 100644 --- a/lib/requester/core.js +++ b/lib/requester/core.js @@ -841,9 +841,13 @@ module.exports = { * RFC 6052), IPv4-translated (::ffff:0:x.x.x.x, RFC 6145), and 6to4 * (2002::/16, RFC 3056) — all are unwrapped to IPv4 before CIDR matching. * - * CIDR entries are parsed from restrictedAddresses on first call and cached - * on the networkOptions object; restrictedAddresses is treated as immutable - * after the first call — later mutations to CIDR entries are not picked up. + * CIDR entries, and bare-IP exact-match entries (promoted to a /32 or /128 CIDR so + * they get the same IPv6-embedding normalization below — see the bug this fixes: + * an exact-match entry like '169.254.169.254' used to be checked via string equality + * only, so '::ffff:169.254.169.254'/'[::ffff:a9fe:a9fe]'/other equivalent encodings of + * the exact same address bypassed it entirely), are parsed from restrictedAddresses on + * first call and cached on the networkOptions object; restrictedAddresses is treated as + * immutable after the first call — later mutations are not picked up. * * @param {String} host * @param {Object} networkOptions @@ -863,24 +867,43 @@ module.exports = { strippedHost = hasBrackets ? lowerHost.slice(1, -1) : lowerHost; // exact match — check the original form, the stripped form, and (for bare IPv6 - // addresses) the bracketed form so '[::1]' and '::1' entries are interchangeable + // addresses) the bracketed form so '[::1]' and '::1' entries are interchangeable. + // This is a fast path for hostname entries (e.g. 'internal.corp') and the common + // case; IP entries are also covered below regardless of this branch's outcome. if (restrictedAddresses[lowerHost] || restrictedAddresses[strippedHost] || (!hasBrackets && _.includes(lowerHost, COLON) && restrictedAddresses['[' + lowerHost + ']'])) { return true; } - // lazy-init the parsed CIDR list on first call; cached for subsequent calls + // lazy-init the parsed address/CIDR list on first call; cached for subsequent calls. + // Every entry that parses as an IP — whether written as a CIDR ('10.0.0.0/8') or a + // bare address ('169.254.169.254') — is included here, promoting bare addresses to + // a /32 (IPv4) or /128 (IPv6) so they get matched against the *normalized* address + // below instead of only ever being checked via string equality above. Entries that + // fail to parse as either (e.g. a hostname like 'internal.corp') are skipped here — + // they remain covered by the exact-match branch above only. if (!networkOptions.restrictedCidrs) { networkOptions.restrictedCidrs = Object.keys(restrictedAddresses) .filter(function (key) { - return restrictedAddresses[key] && _.includes(key, '/'); + return restrictedAddresses[key]; }) .reduce(function (acc, key) { - // silently ignore unparseable CIDR entries; the entry simply - // never matches rather than throwing on every lookup - try { acc.push(ipaddr.parseCIDR(key)); } - catch (e) { /* invalid CIDR entry, skip it */ } + var parsed; + + try { + if (_.includes(key, '/')) { + acc.push(ipaddr.parseCIDR(key)); + } + else { + parsed = ipaddr.parse(key); + acc.push([parsed, parsed.kind() === 'ipv6' ? 128 : 32]); + } + } + // silently ignore entries that are neither a valid CIDR nor a valid IP + // (e.g. a hostname entry); the entry simply never matches here rather + // than throwing on every lookup + catch (e) { /* not a parseable IP/CIDR, skip it */ } return acc; }, []); diff --git a/test/unit/requester-core.test.js b/test/unit/requester-core.test.js index 7ce5d3874..b1ee5226f 100644 --- a/test/unit/requester-core.test.js +++ b/test/unit/requester-core.test.js @@ -1165,6 +1165,61 @@ describe('requester util', function () { }); }); + describe('bare exact-match entries get IPv6-embedding normalization too', function () { + // regression: a bare-IP entry (no '/') used to be checked via string equality + // only, so any non-identical encoding of the exact same address bypassed it, + // even when other CIDR entries existed elsewhere in the same restrictedAddresses + it('should block an IPv4-mapped IPv6 form of an exact-match-only entry', function () { + expect(requesterCore.isAddressRestricted('::ffff:169.254.169.254', { + restrictedAddresses: { '169.254.169.254': true } + })).to.be.true; + }); + + it('should block a bracketed IPv4-mapped IPv6 form of an exact-match-only entry', function () { + expect(requesterCore.isAddressRestricted('[::ffff:169.254.169.254]', { + restrictedAddresses: { '169.254.169.254': true } + })).to.be.true; + }); + + it('should block a NAT64-embedded form of an exact-match-only entry', function () { + expect(requesterCore.isAddressRestricted('64:ff9b::a9fe:a9fe', { + restrictedAddresses: { '169.254.169.254': true } + })).to.be.true; + }); + + it('should not block an unrelated IP against an exact-match-only list', function () { + expect(requesterCore.isAddressRestricted('8.8.8.8', { + restrictedAddresses: { '169.254.169.254': true } + })).to.be.false; + }); + + it('should block a mapped-IPv6 form of an exact entry even when a CIDR entry ' + + 'for a different range is also present', function () { + expect(requesterCore.isAddressRestricted('::ffff:127.0.0.1', { + restrictedAddresses: { '127.0.0.1': true, '10.0.0.0/8': true } + })).to.be.true; + }); + + it('should still block a bare IPv6 exact-match entry via a differently-formatted ' + + 'literal of the same address', function () { + expect(requesterCore.isAddressRestricted('0:0:0:0:0:0:0:1', { + restrictedAddresses: { '::1': true } + })).to.be.true; + }); + + it('should not treat a hostname entry as a parseable address (no throw, no match)', function () { + expect(requesterCore.isAddressRestricted('other.corp', { + restrictedAddresses: { 'internal.corp': true } + })).to.be.false; + }); + + it('should still exact-match a hostname entry', function () { + expect(requesterCore.isAddressRestricted('internal.corp', { + restrictedAddresses: { 'internal.corp': true } + })).to.be.true; + }); + }); + describe('CIDR range matching', function () { var ipv4CidrOpts; From c9fefc110c40ca20596aa6c05885f291d0893da9 Mon Sep 17 00:00:00 2001 From: Udit Vasu Date: Mon, 10 Aug 2026 13:14:09 -0700 Subject: [PATCH 2/2] fix requester restricted address normalization --- lib/requester/core.js | 46 +++++++++++++++++--------------- test/unit/requester-core.test.js | 39 +++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/lib/requester/core.js b/lib/requester/core.js index 8b6522856..bc91ff228 100644 --- a/lib/requester/core.js +++ b/lib/requester/core.js @@ -842,12 +842,10 @@ module.exports = { * (2002::/16, RFC 3056) — all are unwrapped to IPv4 before CIDR matching. * * CIDR entries, and bare-IP exact-match entries (promoted to a /32 or /128 CIDR so - * they get the same IPv6-embedding normalization below — see the bug this fixes: - * an exact-match entry like '169.254.169.254' used to be checked via string equality - * only, so '::ffff:169.254.169.254'/'[::ffff:a9fe:a9fe]'/other equivalent encodings of - * the exact same address bypassed it entirely), are parsed from restrictedAddresses on - * first call and cached on the networkOptions object; restrictedAddresses is treated as - * immutable after the first call — later mutations are not picked up. + * they get the same IPv6-embedding normalization below), are parsed from + * restrictedAddresses on first call and cached on the networkOptions object; + * restrictedAddresses is treated as immutable after the first call — later + * mutations are not picked up. * * @param {String} host * @param {Object} networkOptions @@ -857,7 +855,7 @@ module.exports = { */ isAddressRestricted (host, networkOptions) { var restrictedAddresses = networkOptions.restrictedAddresses, - lowerHost, hasBrackets, strippedHost, compatEmbedded, addr, range; + lowerHost, hasBrackets, strippedHost, compatEmbedded, addr, range, parts; if (!(host && restrictedAddresses)) { return false; } @@ -868,42 +866,39 @@ module.exports = { // exact match — check the original form, the stripped form, and (for bare IPv6 // addresses) the bracketed form so '[::1]' and '::1' entries are interchangeable. - // This is a fast path for hostname entries (e.g. 'internal.corp') and the common - // case; IP entries are also covered below regardless of this branch's outcome. + // This is a fast path for hostname entries and already-canonicalized literals; + // IP entries are also covered below via the parsed CIDR/exact-match cache. if (restrictedAddresses[lowerHost] || restrictedAddresses[strippedHost] || (!hasBrackets && _.includes(lowerHost, COLON) && restrictedAddresses['[' + lowerHost + ']'])) { return true; } - // lazy-init the parsed address/CIDR list on first call; cached for subsequent calls. - // Every entry that parses as an IP — whether written as a CIDR ('10.0.0.0/8') or a - // bare address ('169.254.169.254') — is included here, promoting bare addresses to - // a /32 (IPv4) or /128 (IPv6) so they get matched against the *normalized* address - // below instead of only ever being checked via string equality above. Entries that - // fail to parse as either (e.g. a hostname like 'internal.corp') are skipped here — - // they remain covered by the exact-match branch above only. + // lazy-init the parsed address/CIDR list on first call; cached for subsequent calls if (!networkOptions.restrictedCidrs) { networkOptions.restrictedCidrs = Object.keys(restrictedAddresses) .filter(function (key) { return restrictedAddresses[key]; }) .reduce(function (acc, key) { - var parsed; + var parsed, + bareKey = key; try { if (_.includes(key, '/')) { acc.push(ipaddr.parseCIDR(key)); } else { - parsed = ipaddr.parse(key); + if (key[0] === '[' && key[key.length - 1] === ']') { + bareKey = key.slice(1, -1); + } + + parsed = ipaddr.parse(bareKey); acc.push([parsed, parsed.kind() === 'ipv6' ? 128 : 32]); } } // silently ignore entries that are neither a valid CIDR nor a valid IP - // (e.g. a hostname entry); the entry simply never matches here rather - // than throwing on every lookup - catch (e) { /* not a parseable IP/CIDR, skip it */ } + catch (e) { /* invalid restricted address entry, skip it */ } return acc; }, []); @@ -933,6 +928,7 @@ module.exports = { if (addr.kind() === 'ipv6') { range = addr.range(); + parts = addr.parts; if (range === 'rfc6052' || range === 'rfc6145') { // NAT64 (64:ff9b::/96, RFC 6052) and IPv4-translated (::ffff:0:x.x.x.x, RFC 6145) @@ -943,6 +939,14 @@ module.exports = { // 6to4 (2002::/16, RFC 3056) embeds the IPv4 address in bytes 2-5 addr = ipaddr.fromByteArray(addr.toByteArray().slice(2, 6)); } + else if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && + parts[3] === 0 && parts[4] === 0 && parts[5] === 0 && + !(parts[6] === 0 && (parts[7] === 0 || parts[7] === 1))) { + // IPv4-compatible IPv6 in hex form (for example ::7f00:1 or ::7f01) + // keeps the IPv4 payload in the last 32 bits, but ipaddr.js reports + // it as generic IPv6 unicast instead of unwrapping it. + addr = ipaddr.fromByteArray(addr.toByteArray().slice(12)); + } } } diff --git a/test/unit/requester-core.test.js b/test/unit/requester-core.test.js index b1ee5226f..ebfff51a2 100644 --- a/test/unit/requester-core.test.js +++ b/test/unit/requester-core.test.js @@ -1207,17 +1207,35 @@ describe('requester util', function () { })).to.be.true; }); + it('should block an IPv4-compatible hex form of an exact-match-only entry', function () { + expect(requesterCore.isAddressRestricted('::7f00:1', { + restrictedAddresses: { '127.0.0.1': true } + })).to.be.true; + }); + + it('should block a low-32-bit hex form that begins with 0.0', function () { + expect(requesterCore.isAddressRestricted('::7f01', { + restrictedAddresses: { '0.0.127.1': true } + })).to.be.true; + }); + + it('should still block a bracketed IPv6 exact-match entry via an equivalent literal', function () { + expect(requesterCore.isAddressRestricted('0:0:0:0:0:0:0:1', { + restrictedAddresses: { '[::1]': true } + })).to.be.true; + }); + it('should not treat a hostname entry as a parseable address (no throw, no match)', function () { expect(requesterCore.isAddressRestricted('other.corp', { restrictedAddresses: { 'internal.corp': true } })).to.be.false; }); - it('should still exact-match a hostname entry', function () { expect(requesterCore.isAddressRestricted('internal.corp', { restrictedAddresses: { 'internal.corp': true } })).to.be.true; }); + }); describe('CIDR range matching', function () { @@ -1387,6 +1405,24 @@ describe('requester util', function () { restrictedAddresses: { '0.0.0.0/8': true } })).to.be.true; }); + + it('should block ::7f00:1 via a 127.0.0.0/8 CIDR', function () { + expect(requesterCore.isAddressRestricted('::7f00:1', { + restrictedAddresses: { '127.0.0.0/8': true } + })).to.be.true; + }); + + it('should block ::7f01 via a 0.0.0.0/8 CIDR', function () { + expect(requesterCore.isAddressRestricted('::7f01', { + restrictedAddresses: { '0.0.0.0/8': true } + })).to.be.true; + }); + + it('should not match ::1 against an unrelated 0.0.0.0/8 IPv4 CIDR', function () { + expect(requesterCore.isAddressRestricted('::1', { + restrictedAddresses: { '0.0.0.0/8': true } + })).to.be.false; + }); }); describe('NAT64-embedded IPv4 (64:ff9b::/96, RFC 6052)', function () { @@ -1501,4 +1537,3 @@ describe('requester util', function () { }); }); }); -