Skip to content

Commit a989e3b

Browse files
committed
fix(network): canonicalize IPv4-mapped IPv6 on numeric value so all textual forms (compressed/expanded/hex) match uniformly
1 parent 2ca9a67 commit a989e3b

2 files changed

Lines changed: 68 additions & 24 deletions

File tree

packages/platform-authz/src/network.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@ describe('isAddressAllowed', () => {
9292
expect(isAddressAllowed('::ffff:10.0.1.5', mapped)).toBe(false)
9393
})
9494

95+
it('canonicalizes every textual form of a mapped client to the v4 bucket', () => {
96+
// A plain IPv4 entry must match a mapped client written in compressed,
97+
// expanded, or hex form — all denote 192.0.2.5.
98+
const v4entry = compileAllowlist(['192.0.2.0/24'])
99+
expect(isAddressAllowed('::ffff:192.0.2.5', v4entry)).toBe(true)
100+
expect(isAddressAllowed('0:0:0:0:0:ffff:192.0.2.5', v4entry)).toBe(true)
101+
expect(isAddressAllowed('::ffff:c000:0205', v4entry)).toBe(true)
102+
expect(isAddressAllowed('::ffff:192.0.3.5', v4entry)).toBe(false)
103+
104+
// And a mapped-form CIDR entry matches an expanded/hex mapped client.
105+
const mappedEntry = compileAllowlist(['::ffff:192.0.2.0/120'])
106+
expect(mappedEntry.v4).toHaveLength(1)
107+
expect(isAddressAllowed('0:0:0:0:0:ffff:192.0.2.9', mappedEntry)).toBe(true)
108+
expect(isAddressAllowed('::ffff:c000:0209', mappedEntry)).toBe(true)
109+
})
110+
111+
it('does not treat genuine IPv6 in the ffff block boundary as mapped', () => {
112+
// 0:0:0:0:1:ffff:... is NOT the ::ffff:0:0/96 mapped range.
113+
const v6entry = compileAllowlist(['0:0:0:0:1:ffff::/96'])
114+
expect(v6entry.v6).toHaveLength(1)
115+
expect(isAddressAllowed('0:0:0:0:1:ffff:192.0.2.5', v6entry)).toBe(true)
116+
expect(isAddressAllowed('192.0.2.5', v6entry)).toBe(false)
117+
})
118+
95119
it('labels never affect matching', () => {
96120
const labelled = compileAllowlist(['10.0.0.0/16 # Frankfurt VPN'])
97121
expect(isAddressAllowed('10.0.5.5', labelled)).toBe(true)

packages/platform-authz/src/network.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -85,27 +85,26 @@ function stripEntryLabel(entry: string): string {
8585
return (hashIndex === -1 ? entry : entry.slice(0, hashIndex)).trim()
8686
}
8787

88+
/**
89+
* IPv4-mapped IPv6 range is `::ffff:0:0/96` — the low 32 bits are the IPv4
90+
* address and the 96 bits above them equal `0x…0000ffff`. Detecting this on
91+
* the PARSED numeric value (not the string) canonicalizes every textual form
92+
* of a mapped address identically — compressed (`::ffff:192.0.2.5`), expanded
93+
* (`0:0:0:0:0:ffff:192.0.2.5`), and hex (`::ffff:c000:0205`) all parse to the
94+
* same bigint — so entries and client addresses land in the same family
95+
* bucket regardless of how they were written.
96+
*/
97+
const IPV4_MAPPED_PREFIX = 0xffffn
98+
99+
function mappedV6ToIpv4(value: bigint): number | null {
100+
return value >> 32n === IPV4_MAPPED_PREFIX ? Number(value & 0xffffffffn) : null
101+
}
102+
88103
/** Parses an allowlist entry (bare IP or CIDR, optional label), or null when malformed. */
89104
function parseCidr(entry: string): ParsedCidr | null {
90105
const [host, prefixPart, ...rest] = stripEntryLabel(entry).split('/')
91106
if (rest.length > 0) return null
92107

93-
// An IPv4-mapped IPv6 host (`::ffff:a.b.c.d`) is canonicalized to IPv4 so it
94-
// lands in the same family bucket as the client addresses isAddressAllowed
95-
// demotes — otherwise a mapped-form entry compiles into the v6 list and a
96-
// mapped client, demoted to v4, would never match it. A prefix on the
97-
// mapped form addresses the full 128-bit space, so its meaningful range is
98-
// the low 32 bits (>= /96); demote it by 96.
99-
const mappedV4 = host.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i)
100-
if (mappedV4) {
101-
const v4Mapped = parseIpv4(mappedV4[1])
102-
if (v4Mapped === null) return null
103-
if (prefixPart === undefined) return { kind: 'v4', value: v4Mapped, prefix: 32 }
104-
const mappedPrefix = Number(prefixPart)
105-
if (!Number.isInteger(mappedPrefix) || mappedPrefix < 96 || mappedPrefix > 128) return null
106-
return { kind: 'v4', value: v4Mapped, prefix: mappedPrefix - 96 }
107-
}
108-
109108
const v4 = parseIpv4(host)
110109
if (v4 !== null) {
111110
const prefix = prefixPart === undefined ? 32 : Number(prefixPart)
@@ -115,6 +114,18 @@ function parseCidr(entry: string): ParsedCidr | null {
115114

116115
const v6 = parseIpv6(host)
117116
if (v6 !== null) {
117+
// Canonicalize an IPv4-mapped IPv6 host to IPv4 (any textual form) so it
118+
// shares a bucket with the mapped client addresses isAddressAllowed also
119+
// demotes. A prefix on the mapped form addresses the full 128-bit space,
120+
// so its meaningful range is the low 32 bits (>= /96); demote it by 96.
121+
const mapped = mappedV6ToIpv4(v6)
122+
if (mapped !== null) {
123+
if (prefixPart === undefined) return { kind: 'v4', value: mapped, prefix: 32 }
124+
const mappedPrefix = Number(prefixPart)
125+
if (!Number.isInteger(mappedPrefix) || mappedPrefix < 96 || mappedPrefix > 128) return null
126+
return { kind: 'v4', value: mapped, prefix: mappedPrefix - 96 }
127+
}
128+
118129
const prefix = prefixPart === undefined ? 128 : Number(prefixPart)
119130
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 128) return null
120131
return { kind: 'v6', value: v6, prefix }
@@ -156,23 +167,32 @@ export function compileAllowlist(entries: readonly string[]): CompiledAllowlist
156167
return compiled
157168
}
158169

170+
function matchV4(v4: number, allowlist: CompiledAllowlist): boolean {
171+
return allowlist.v4.some((entry) => (v4 & entry.mask) >>> 0 === entry.network)
172+
}
173+
159174
/**
160175
* True when `address` (an IPv4 or IPv6 client address, no CIDR suffix) is
161-
* inside the compiled allowlist. IPv4-mapped IPv6 addresses
162-
* (`::ffff:a.b.c.d`) match against the v4 entries. Unparseable addresses
163-
* never match.
176+
* inside the compiled allowlist. IPv4-mapped IPv6 addresses — in any textual
177+
* form (`::ffff:a.b.c.d`, `0:0:0:0:0:ffff:a.b.c.d`, `::ffff:hhhh:hhhh`) —
178+
* match against the v4 entries, since they canonicalize to the same value.
179+
* Unparseable addresses never match.
164180
*/
165181
export function isAddressAllowed(address: string, allowlist: CompiledAllowlist): boolean {
166182
const trimmed = address.trim()
167183

168-
const mapped = trimmed.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i)
169-
const v4 = parseIpv4(mapped ? mapped[1] : trimmed)
170-
if (v4 !== null) {
171-
return allowlist.v4.some((entry) => (v4 & entry.mask) >>> 0 === entry.network)
172-
}
184+
const v4 = parseIpv4(trimmed)
185+
if (v4 !== null) return matchV4(v4, allowlist)
173186

174187
const v6 = parseIpv6(trimmed)
175188
if (v6 === null) return false
189+
190+
// A mapped IPv6 client is checked against the v4 entries (the parser
191+
// canonicalizes every textual form to the same value); only genuine IPv6
192+
// falls through to the v6 entries.
193+
const mapped = mappedV6ToIpv4(v6)
194+
if (mapped !== null) return matchV4(mapped, allowlist)
195+
176196
return allowlist.v6.some((entry) => {
177197
if (entry.prefix === 0) return true
178198
const shift = BigInt(128 - entry.prefix)

0 commit comments

Comments
 (0)