@@ -23,9 +23,64 @@ function isPrivateIPv4(address: string): boolean {
2323}
2424
2525function isPrivateIPv6 ( address : string ) : boolean {
26- const normalized = address . toLowerCase ( ) ;
27- if ( normalized === "::1" ) return true ; // loopback
28- if ( normalized . startsWith ( "fe80:" ) ) return true ; // fe80::/10 link-local
29- if ( / ^ f [ c d ] [ 0 - 9 a - f ] { 2 } : / . test ( normalized ) ) return true ; // fc00::/7 unique local
26+ // Normalize to the full 8x16-bit group form so shorthand ("::"), expanded,
27+ // and IPv4-embedded spellings all classify identically. Fail closed on
28+ // anything that does not normalize cleanly.
29+ const groups = expandIPv6 ( address ) ;
30+ if ( ! groups ) return false ;
31+
32+ if ( groups . slice ( 0 , 7 ) . every ( ( g ) => g === 0 ) && groups [ 7 ] === 1 ) return true ; // ::1 loopback
33+ if ( groups . slice ( 0 , 5 ) . every ( ( g ) => g === 0 ) && groups [ 5 ] === 0xffff ) {
34+ // ::ffff:a.b.c.d IPv4-mapped -- classify by the embedded IPv4
35+ return isPrivateIPv4 ( embeddedIPv4 ( groups [ 6 ] , groups [ 7 ] ) ) ;
36+ }
37+ if (
38+ groups [ 0 ] === 0x0064 &&
39+ groups [ 1 ] === 0xff9b &&
40+ groups . slice ( 2 , 6 ) . every ( ( g ) => g === 0 )
41+ ) {
42+ // 64:ff9b::/96 NAT64 -- classify by the embedded IPv4
43+ return isPrivateIPv4 ( embeddedIPv4 ( groups [ 6 ] , groups [ 7 ] ) ) ;
44+ }
45+ if ( groups [ 0 ] === 0x2002 ) {
46+ // 2002::/16 6to4 -- classify by the embedded IPv4
47+ return isPrivateIPv4 ( embeddedIPv4 ( groups [ 1 ] , groups [ 2 ] ) ) ;
48+ }
49+ if ( ( groups [ 0 ] & 0xffc0 ) === 0xfe80 ) return true ; // fe80::/10 link-local
50+ if ( ( groups [ 0 ] & 0xfe00 ) === 0xfc00 ) return true ; // fc00::/7 unique local
3051 return false ;
3152}
53+
54+ /** Expands a valid IPv6 address into its 8 16-bit groups, or null if it cannot. */
55+ function expandIPv6 ( address : string ) : number [ ] | null {
56+ let addr = address . toLowerCase ( ) ;
57+
58+ // Rewrite a trailing dotted-quad (e.g. "::ffff:192.168.1.1") as two hex groups.
59+ const v4Match = addr . match ( / ^ ( .* : ) ( \d { 1 , 3 } \. \d { 1 , 3 } \. \d { 1 , 3 } \. \d { 1 , 3 } ) $ / ) ;
60+ if ( v4Match ) {
61+ const octets = v4Match [ 2 ] . split ( "." ) . map ( Number ) ;
62+ if ( octets . some ( ( o ) => o > 255 ) ) return null ;
63+ const hi = ( ( octets [ 0 ] << 8 ) | octets [ 1 ] ) . toString ( 16 ) ;
64+ const lo = ( ( octets [ 2 ] << 8 ) | octets [ 3 ] ) . toString ( 16 ) ;
65+ addr = `${ v4Match [ 1 ] } ${ hi } :${ lo } ` ;
66+ }
67+
68+ const halves = addr . split ( "::" ) ;
69+ if ( halves . length > 2 ) return null ;
70+ const head = halves [ 0 ] ? halves [ 0 ] . split ( ":" ) : [ ] ;
71+ const tail = halves . length === 2 && halves [ 1 ] ? halves [ 1 ] . split ( ":" ) : [ ] ;
72+ const missing = 8 - head . length - tail . length ;
73+ if ( halves . length === 2 ? missing < 0 : head . length !== 8 ) return null ;
74+ const parts = halves . length === 2 ? [ ...head , ...Array ( missing ) . fill ( "0" ) , ...tail ] : head ;
75+
76+ const groups : number [ ] = [ ] ;
77+ for ( const part of parts ) {
78+ if ( ! / ^ [ 0 - 9 a - f ] { 1 , 4 } $ / . test ( part ) ) return null ;
79+ groups . push ( Number . parseInt ( part , 16 ) ) ;
80+ }
81+ return groups . length === 8 ? groups : null ;
82+ }
83+
84+ function embeddedIPv4 ( hi : number , lo : number ) : string {
85+ return `${ hi >> 8 } .${ hi & 0xff } .${ lo >> 8 } .${ lo & 0xff } ` ;
86+ }
0 commit comments