@@ -18,6 +18,8 @@ const urlEncodePatterns = [
1818// RegExp patterns to URL-decode special characters for local filesystem paths
1919const urlDecodePatterns = [ / % 2 3 / g, "#" , / % 2 4 / g, "$" , / % 2 6 / g, "&" , / % 2 C / g, "," , / % 4 0 / g, "@" ] ;
2020
21+ const unsafeDomainSuffixes = [ ".localhost" , ".local" , ".internal" , ".intranet" , ".corp" , ".home" , ".lan" ] ;
22+
2123export const parse = ( u : string | URL ) => new URL ( u ) ;
2224
2325/**
@@ -211,60 +213,11 @@ export function isUnsafeUrl(path: string | unknown): boolean {
211213 return false ;
212214 }
213215
214- // Local/internal network addresses
215- const localPatterns = [
216- // Localhost variations
217- "localhost" ,
218- "127.0.0.1" ,
219- "::1" ,
220-
221- // Private IP ranges (RFC 1918)
222- "10." ,
223- "172.16." ,
224- "172.17." ,
225- "172.18." ,
226- "172.19." ,
227- "172.20." ,
228- "172.21." ,
229- "172.22." ,
230- "172.23." ,
231- "172.24." ,
232- "172.25." ,
233- "172.26." ,
234- "172.27." ,
235- "172.28." ,
236- "172.29." ,
237- "172.30." ,
238- "172.31." ,
239- "192.168." ,
240-
241- // Link-local addresses
242- "169.254." ,
243-
244- // Internal domains
245- ".local" ,
246- ".internal" ,
247- ".intranet" ,
248- ".corp" ,
249- ".home" ,
250- ".lan" ,
251- ] ;
252-
253216 try {
254217 // Try to parse as URL
255218 const url = new URL ( normalizedPath . startsWith ( "//" ) ? "http:" + normalizedPath : normalizedPath ) ;
256219
257- const hostname = url . hostname . toLowerCase ( ) ;
258-
259- // Check against local patterns
260- for ( const pattern of localPatterns ) {
261- if ( hostname === pattern || hostname . startsWith ( pattern ) || hostname . endsWith ( pattern ) ) {
262- return true ;
263- }
264- }
265-
266- // Check for IP addresses in private ranges
267- if ( isPrivateIP ( hostname ) ) {
220+ if ( isUnsafeHostname ( url . hostname ) ) {
268221 return true ;
269222 }
270223
@@ -281,41 +234,207 @@ export function isUnsafeUrl(path: string | unknown): boolean {
281234 return false ;
282235 }
283236
284- // Check for localhost patterns in non-URL strings
285- for ( const pattern of localPatterns ) {
286- if ( normalizedPath . includes ( pattern ) ) {
287- return true ;
288- }
237+ if ( containsUnsafeHostname ( normalizedPath ) ) {
238+ return true ;
289239 }
290240 }
291241
292242 return false ;
293243}
294244
295245/**
296- * Helper function to check if an IP address is in a private range
246+ * Helper function to check if a hostname is local or resolves to a non-public literal address.
297247 */
298- function isPrivateIP ( ip : string ) : boolean {
299- const ipRegex = / ^ ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) $ / ;
300- const match = ip . match ( ipRegex ) ;
248+ function isUnsafeHostname ( hostname : string ) : boolean {
249+ const normalizedHostname = normalizeHostname ( hostname ) ;
301250
302- if ( ! match ) {
303- return false ;
251+ if ( ! normalizedHostname ) {
252+ return true ;
304253 }
305254
306- const [ , a , b , c , d ] = match . map ( Number ) ;
255+ if ( normalizedHostname === "localhost" || unsafeDomainSuffixes . some ( ( suffix ) => normalizedHostname . endsWith ( suffix ) ) ) {
256+ return true ;
257+ }
307258
308- // Validate IP format
309- if ( a > 255 || b > 255 || c > 255 || d > 255 ) {
310- return false ;
259+ const ipv4 = parseIPv4Address ( normalizedHostname ) ;
260+ if ( ipv4 ) {
261+ return isUnsafeIPv4Address ( ipv4 ) ;
262+ }
263+
264+ const ipv6 = parseIPv6Address ( normalizedHostname ) ;
265+ if ( ipv6 ) {
266+ return isUnsafeIPv6Address ( ipv6 ) ;
311267 }
312268
313- // Private IP ranges
269+ return false ;
270+ }
271+
272+ function normalizeHostname ( hostname : string ) : string {
273+ let normalizedHostname = hostname . trim ( ) . toLowerCase ( ) ;
274+
275+ if ( normalizedHostname . startsWith ( "[" ) && normalizedHostname . endsWith ( "]" ) ) {
276+ normalizedHostname = normalizedHostname . slice ( 1 , - 1 ) ;
277+ }
278+
279+ while ( normalizedHostname . endsWith ( "." ) ) {
280+ normalizedHostname = normalizedHostname . slice ( 0 , - 1 ) ;
281+ }
282+
283+ return normalizedHostname ;
284+ }
285+
286+ function parseIPv4Address ( ip : string ) : number [ ] | undefined {
287+ const parts = ip . split ( "." ) ;
288+
289+ if ( parts . length !== 4 ) {
290+ return undefined ;
291+ }
292+
293+ const octets = parts . map ( ( part ) => {
294+ if ( ! / ^ \d + $ / . test ( part ) ) {
295+ return Number . NaN ;
296+ }
297+
298+ return Number ( part ) ;
299+ } ) ;
300+
301+ if ( octets . some ( ( octet ) => ! Number . isInteger ( octet ) || octet < 0 || octet > 255 ) ) {
302+ return undefined ;
303+ }
304+
305+ return octets ;
306+ }
307+
308+ /**
309+ * Helper function to check if an IPv4 address is in a non-public range.
310+ */
311+ function isUnsafeIPv4Address ( [ a , b , c , d ] : number [ ] ) : boolean {
314312 return (
315- a === 10 || a === 127 || ( a === 172 && b >= 16 && b <= 31 ) || ( a === 192 && b === 168 ) || ( a === 169 && b === 254 ) // Link-local
313+ a === 0 ||
314+ a === 10 ||
315+ a === 127 ||
316+ ( a === 100 && b >= 64 && b <= 127 ) ||
317+ ( a === 169 && b === 254 ) ||
318+ ( a === 172 && b >= 16 && b <= 31 ) ||
319+ ( a === 192 && b === 0 && c === 0 ) ||
320+ ( a === 192 && b === 168 ) ||
321+ ( a === 198 && ( b === 18 || b === 19 ) ) ||
322+ a >= 224 ||
323+ ( a === 255 && b === 255 && c === 255 && d === 255 )
316324 ) ;
317325}
318326
327+ function parseIPv6Address ( ip : string ) : number [ ] | undefined {
328+ if ( ! ip . includes ( ":" ) ) {
329+ return undefined ;
330+ }
331+
332+ let normalizedIP = ip ;
333+ const lastSeparator = normalizedIP . lastIndexOf ( ":" ) ;
334+ const possibleIPv4 = normalizedIP . slice ( lastSeparator + 1 ) ;
335+
336+ if ( possibleIPv4 . includes ( "." ) ) {
337+ const ipv4 = parseIPv4Address ( possibleIPv4 ) ;
338+
339+ if ( ! ipv4 ) {
340+ return undefined ;
341+ }
342+
343+ const firstGroup = ipv4 [ 0 ] * 256 + ipv4 [ 1 ] ;
344+ const secondGroup = ipv4 [ 2 ] * 256 + ipv4 [ 3 ] ;
345+ normalizedIP = `${ normalizedIP . slice ( 0 , lastSeparator + 1 ) } ${ firstGroup . toString ( 16 ) } :${ secondGroup . toString ( 16 ) } ` ;
346+ }
347+
348+ const halves = normalizedIP . split ( "::" ) ;
349+
350+ if ( halves . length > 2 ) {
351+ return undefined ;
352+ }
353+
354+ const head = parseIPv6Groups ( halves [ 0 ] ) ;
355+ const tail = halves . length === 2 ? parseIPv6Groups ( halves [ 1 ] ) : [ ] ;
356+
357+ if ( ! head || ! tail ) {
358+ return undefined ;
359+ }
360+
361+ if ( halves . length === 1 ) {
362+ return head . length === 8 ? head : undefined ;
363+ }
364+
365+ const missingGroups = 8 - head . length - tail . length ;
366+
367+ if ( missingGroups < 1 ) {
368+ return undefined ;
369+ }
370+
371+ return [ ...head , ...Array < number > ( missingGroups ) . fill ( 0 ) , ...tail ] ;
372+ }
373+
374+ function parseIPv6Groups ( groups : string ) : number [ ] | undefined {
375+ if ( ! groups ) {
376+ return [ ] ;
377+ }
378+
379+ const parsedGroups = groups . split ( ":" ) . map ( ( group ) => {
380+ if ( ! / ^ [ \d a - f ] { 1 , 4 } $ / i. test ( group ) ) {
381+ return Number . NaN ;
382+ }
383+
384+ return Number . parseInt ( group , 16 ) ;
385+ } ) ;
386+
387+ if ( parsedGroups . some ( ( group ) => ! Number . isInteger ( group ) || group < 0 || group > 0xffff ) ) {
388+ return undefined ;
389+ }
390+
391+ return parsedGroups ;
392+ }
393+
394+ /**
395+ * Helper function to check if an IPv6 address is in a non-public range.
396+ */
397+ function isUnsafeIPv6Address ( groups : number [ ] ) : boolean {
398+ if ( groups . length !== 8 ) {
399+ return false ;
400+ }
401+
402+ const isUnspecified = groups . every ( ( group ) => group === 0 ) ;
403+ const isLoopback = groups . slice ( 0 , 7 ) . every ( ( group ) => group === 0 ) && groups [ 7 ] === 1 ;
404+ const isUniqueLocal = ( groups [ 0 ] & 0xfe00 ) === 0xfc00 ;
405+ const isLinkLocal = ( groups [ 0 ] & 0xffc0 ) === 0xfe80 ;
406+ const isMulticast = ( groups [ 0 ] & 0xff00 ) === 0xff00 ;
407+
408+ if ( isUnspecified || isLoopback || isUniqueLocal || isLinkLocal || isMulticast ) {
409+ return true ;
410+ }
411+
412+ const mappedIPv4 = getMappedIPv4Address ( groups ) ;
413+
414+ return mappedIPv4 ? isUnsafeIPv4Address ( mappedIPv4 ) : false ;
415+ }
416+
417+ function getMappedIPv4Address ( groups : number [ ] ) : number [ ] | undefined {
418+ const firstFiveGroupsAreZero = groups . slice ( 0 , 5 ) . every ( ( group ) => group === 0 ) ;
419+ const firstSixGroupsAreZero = firstFiveGroupsAreZero && groups [ 5 ] === 0 ;
420+ const isIPv4Mapped = firstFiveGroupsAreZero && groups [ 5 ] === 0xffff ;
421+
422+ if ( ! firstSixGroupsAreZero && ! isIPv4Mapped ) {
423+ return undefined ;
424+ }
425+
426+ return [ Math . floor ( groups [ 6 ] / 256 ) , groups [ 6 ] % 256 , Math . floor ( groups [ 7 ] / 256 ) , groups [ 7 ] % 256 ] ;
427+ }
428+
429+ function containsUnsafeHostname ( value : string ) : boolean {
430+ const candidates = value
431+ . split ( / [ \s / ? # ] + / )
432+ . map ( ( candidate ) => candidate . replace ( / ^ [ a - z ] [ \d + . a - z - ] * : \/ \/ / i, "" ) . replace ( / : \d + $ / , "" ) )
433+ . filter ( Boolean ) ;
434+
435+ return candidates . some ( ( candidate ) => isUnsafeHostname ( candidate ) ) ;
436+ }
437+
319438/**
320439 * Helper function to check if a port is typically used for internal services
321440 */
0 commit comments