@@ -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. */
89104function 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 ( / ^ : : f f f f : ( \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 */
165181export function isAddressAllowed ( address : string , allowlist : CompiledAllowlist ) : boolean {
166182 const trimmed = address . trim ( )
167183
168- const mapped = trimmed . match ( / ^ : : f f f f : ( \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