44 * Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
55 */
66
7+ import ipaddr from 'ipaddr.js'
8+
79/** Trusted image domains that don't need proxying (first-party or well-known CDNs) */
810const TRUSTED_IMAGE_DOMAINS = [
911 // First-party
@@ -56,6 +58,8 @@ export function isTrustedImageDomain(url: string): boolean {
5658
5759/**
5860 * Validate that a URL is a valid HTTP(S) image URL suitable for proxying.
61+ * Blocks private/reserved IPs (SSRF protection) using ipaddr.js for comprehensive
62+ * IPv4, IPv6, and IPv4-mapped IPv6 range detection.
5963 */
6064export function isAllowedImageUrl ( url : string ) : boolean {
6165 const parsed = URL . parse ( url )
@@ -66,44 +70,24 @@ export function isAllowedImageUrl(url: string): boolean {
6670 return false
6771 }
6872
69- // Block localhost / private IPs to prevent SSRF
7073 const hostname = parsed . hostname . toLowerCase ( )
71- if (
72- hostname === 'localhost' ||
73- hostname === '127.0.0.1' ||
74- hostname === '0.0.0.0' ||
75- hostname . startsWith ( '10.' ) ||
76- hostname . startsWith ( '192.168.' ) ||
77- // RFC 1918: 172.16.0.0 – 172.31.255.255
78- / ^ 1 7 2 \. ( 1 [ 6 - 9 ] | 2 \d | 3 [ 0 1 ] ) \. / . test ( hostname ) ||
79- // Link-local (cloud metadata: 169.254.169.254)
80- hostname . startsWith ( '169.254.' ) ||
81- hostname . endsWith ( '.local' ) ||
82- hostname . endsWith ( '.internal' ) ||
83- // IPv6 loopback
84- hostname === '::1' ||
85- hostname === '[::1]' ||
86- // IPv6 link-local
87- hostname . startsWith ( 'fe80:' ) ||
88- hostname . startsWith ( '[fe80:' ) ||
89- // IPv6 unique local (fc00::/7)
90- hostname . startsWith ( 'fc' ) ||
91- hostname . startsWith ( 'fd' ) ||
92- hostname . startsWith ( '[fc' ) ||
93- hostname . startsWith ( '[fd' ) ||
94- // IPv4-mapped IPv6 addresses
95- hostname . startsWith ( '::ffff:127.' ) ||
96- hostname . startsWith ( '::ffff:10.' ) ||
97- hostname . startsWith ( '::ffff:192.168.' ) ||
98- hostname . startsWith ( '::ffff:169.254.' ) ||
99- hostname . startsWith ( '[::ffff:127.' ) ||
100- hostname . startsWith ( '[::ffff:10.' ) ||
101- hostname . startsWith ( '[::ffff:192.168.' ) ||
102- hostname . startsWith ( '[::ffff:169.254.' )
103- ) {
74+
75+ // Block non-IP hostnames that resolve to internal services
76+ if ( hostname === 'localhost' || hostname . endsWith ( '.local' ) || hostname . endsWith ( '.internal' ) ) {
10477 return false
10578 }
10679
80+ // For IP addresses, use ipaddr.js to check against all reserved ranges
81+ // (loopback, private RFC 1918, link-local 169.254, IPv6 ULA fc00::/7, etc.)
82+ // ipaddr.process() also unwraps IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 → 127.0.0.1)
83+ const bare = hostname . startsWith ( '[' ) && hostname . endsWith ( ']' ) ? hostname . slice ( 1 , - 1 ) : hostname
84+ if ( ipaddr . isValid ( bare ) ) {
85+ const addr = ipaddr . process ( bare )
86+ if ( addr . range ( ) !== 'unicast' ) {
87+ return false
88+ }
89+ }
90+
10791 return true
10892}
10993
0 commit comments