@@ -50,8 +50,6 @@ const default_url_options = {
5050 max_allowed_length : 2084 ,
5151} ;
5252
53- const wrapped_ipv6 = / ^ \[ ( [ ^ \] ] + ) \] (?: : ( [ 0 - 9 ] + ) ) ? $ / ;
54-
5553export default function isURL ( url , options ) {
5654 assertString ( url ) ;
5755 if ( ! url || / [ \s < > ] / . test ( url ) ) {
@@ -60,7 +58,24 @@ export default function isURL(url, options) {
6058 if ( url . indexOf ( 'mailto:' ) === 0 ) {
6159 return false ;
6260 }
63- options = merge ( options , default_url_options ) ;
61+
62+ // Security check: Reject URLs with Unicode characters that could be dangerous protocol spoofs
63+ // Convert full-width Unicode to ASCII and check for dangerous protocols
64+ const normalizedUrl = url . replace ( / [ \uFF00 - \uFFEF ] / g, ( char ) => {
65+ const code = char . charCodeAt ( 0 ) ;
66+ if ( code >= 0xFF01 && code <= 0xFF5E ) {
67+ return String . fromCharCode ( code - 0xFEE0 ) ;
68+ }
69+ return char ;
70+ } ) ;
71+
72+ /* eslint-disable no-script-url */
73+ const dangerousProtocolPrefixes = [ 'javascript:' , 'data:' , 'vbscript:' ] ;
74+ /* eslint-enable no-script-url */
75+ if ( dangerousProtocolPrefixes . some ( protocol =>
76+ normalizedUrl . toLowerCase ( ) . startsWith ( protocol ) ) ) {
77+ return false ;
78+ } options = merge ( options , default_url_options ) ;
6479
6580 if ( options . validate_length && url . length > options . max_allowed_length ) {
6681 return false ;
@@ -70,110 +85,253 @@ export default function isURL(url, options) {
7085 return false ;
7186 }
7287
73- if ( ! options . allow_query_components && ( includes ( url , '?' ) || includes ( url , '&' ) ) ) {
88+ if (
89+ ! options . allow_query_components &&
90+ ( includes ( url , '?' ) || includes ( url , '&' ) )
91+ ) {
7492 return false ;
7593 }
7694
77- let protocol , auth , host , hostname , port , port_str , split , ipv6 ;
78- let has_protocol = false ;
95+ let originalUrl = url ;
96+ let hasProtocol = false ;
97+ let isProtocolRelative = false ;
7998
80- split = url . split ( '#' ) ;
81- url = split . shift ( ) ;
99+ // Check for multiple slashes like ////foobar.com or http:////foobar.com
100+ // But allow file:/// which is a valid file URL pattern
101+ if (
102+ url . startsWith ( '///' ) ||
103+ ( originalUrl . match ( / : \/ \/ \/ \/ + / ) && ! originalUrl . startsWith ( 'file:///' ) )
104+ ) {
105+ return false ;
106+ }
82107
83- split = url . split ( '?' ) ;
84- url = split . shift ( ) ;
108+ // Check for protocol-relative URLs (must start with exactly //)
109+ if ( url . startsWith ( '//' ) && ! url . startsWith ( '///' ) ) {
110+ if ( ! options . allow_protocol_relative_urls ) {
111+ return false ;
112+ }
113+ isProtocolRelative = true ;
114+ hasProtocol = true ;
115+ url = `http:${ url } ` ; // Temporarily add protocol for parsing
116+ } else if ( / ^ [ a - z A - Z ] [ a - z A - Z 0 - 9 + . - ] * : / . test ( url ) ) {
117+ // Only check for auth-like patterns if there's no :// in the URL (not a real protocol)
118+ if ( ! originalUrl . includes ( '://' ) ) {
119+ // Special case: check if this looks like auth info rather than a protocol
120+ // Pattern: word:something@domain (but not common protocols)
121+ const authLikeMatch = originalUrl . match ( / ^ ( [ ^ : / @ ] + ) : ( [ ^ @ ] * @ [ ^ / ] + ) / ) ;
122+ if ( authLikeMatch ) {
123+ const possibleProtocol = authLikeMatch [ 1 ] . toLowerCase ( ) ;
124+
125+ // Normalize Unicode full-width characters to ASCII for security check
126+ const normalizedProtocol = possibleProtocol . replace ( / [ \uFF00 - \uFFEF ] / g, ( char ) => {
127+ const code = char . charCodeAt ( 0 ) ;
128+ // Convert full-width ASCII to regular ASCII
129+ if ( code >= 0xFF01 && code <= 0xFF5E ) {
130+ return String . fromCharCode ( code - 0xFEE0 ) ;
131+ }
132+ return char ;
133+ } ) ;
134+
135+ const knownDangerousProtocols = [ 'javascript' , 'data' , 'vbscript' ] ;
85136
86- split = url . split ( '://' ) ;
87- if ( split . length > 1 ) {
88- has_protocol = true ;
89- protocol = split . shift ( ) . toLowerCase ( ) ;
90- if ( options . require_valid_protocol && options . protocols . indexOf ( protocol ) === - 1 ) {
137+ if ( ! knownDangerousProtocols . includes ( possibleProtocol ) &&
138+ ! knownDangerousProtocols . includes ( normalizedProtocol ) ) {
139+ // This looks like auth info, treat as no protocol
140+ hasProtocol = false ; // Important: mark as no protocol since we're adding one
141+ url = `http://${ url } ` ;
142+ } else {
143+ hasProtocol = true ;
144+ // This is a dangerous protocol in auth component (CVE-2025-56200)
145+ return false ;
146+ }
147+ } else {
148+ hasProtocol = true ;
149+ }
150+ } else {
151+ hasProtocol = true ;
152+ }
153+ } else {
154+ // Single slash should not be treated as protocol-relative
155+ if ( url . startsWith ( '/' ) && ! url . startsWith ( '//' ) ) {
91156 return false ;
92157 }
93- } else if ( options . require_protocol ) {
94- return false ;
95- } else if ( url . slice ( 0 , 2 ) === '//' ) {
96- if ( ! options . allow_protocol_relative_urls ) {
158+
159+ // No protocol, add a temporary one for parsing
160+ url = `http://${ url } ` ;
161+ }
162+
163+ let parsedUrl ;
164+
165+ // Special handling for database URLs like postgres://user:pw@/test
166+ if (
167+ originalUrl . match ( / ^ [ a - z A - Z ] [ a - z A - Z 0 - 9 + . - ] * : \/ \/ [ ^ @ \/ ] + @ \/ / ) &&
168+ ! options . require_host
169+ ) {
170+ // This is a database URL with empty hostname but auth and path
171+ try {
172+ // Replace @/ with @localhost / temporarily for parsing
173+ const tempUrl = url . replace ( '@/' , '@localhost/' ) ;
174+ parsedUrl = new URL ( tempUrl ) ;
175+ // Clear the hostname since it was fake
176+ Object . defineProperty ( parsedUrl , 'hostname' , {
177+ value : '' ,
178+ writable : false ,
179+ } ) ;
180+ Object . defineProperty ( parsedUrl , 'host' , { value : '' , writable : false } ) ;
181+ } catch ( e ) {
182+ return false ;
183+ }
184+ } else {
185+ // Use native URL constructor for parsing
186+ try {
187+ parsedUrl = new URL ( url ) ;
188+ } catch ( e ) {
97189 return false ;
98190 }
99- has_protocol = true ;
100- split [ 0 ] = url . slice ( 2 ) ;
101191 }
102- url = split . join ( '://' ) ;
103192
104- if ( url === '' ) {
193+ // Validate protocol
194+ const protocol = parsedUrl . protocol . slice ( 0 , - 1 ) ; // Remove trailing ':'
195+ if (
196+ hasProtocol &&
197+ options . require_valid_protocol &&
198+ ! options . protocols . includes ( protocol )
199+ ) {
200+ return false ;
201+ }
202+ if ( ! hasProtocol && options . require_protocol ) {
203+ return false ;
204+ }
205+ if ( isProtocolRelative && options . require_protocol ) {
105206 return false ;
106207 }
107208
108- split = url . split ( '/' ) ;
109- url = split . shift ( ) ;
209+ // Handle special case for URLs ending with just protocol:// (should always fail)
210+ // But allow URLs like file:/// that have paths
211+ if (
212+ ! parsedUrl . hostname &&
213+ hasProtocol &&
214+ originalUrl . endsWith ( '://' ) &&
215+ ( ! parsedUrl . pathname || parsedUrl . pathname === '/' )
216+ ) {
217+ return false ;
218+ }
110219
111- if ( url === '' && ! options . require_host ) {
220+ // Validate host presence
221+ if ( ! parsedUrl . hostname && options . require_host ) {
222+ return false ;
223+ }
224+ if ( ! parsedUrl . hostname && ! options . require_host ) {
112225 return true ;
113226 }
114227
115- split = url . split ( '@' ) ;
116- if ( split . length > 1 ) {
117- if ( options . disallow_auth ) {
118- return false ;
119- }
120- if ( split [ 0 ] === '' ) {
121- return false ;
122- }
123- auth = split . shift ( ) ;
124- if ( ! has_protocol && auth . indexOf ( ':' ) !== - 1 ) {
125- return false ;
126- }
127- if ( auth . indexOf ( ':' ) >= 0 && auth . split ( ':' ) . length > 2 ) {
128- return false ;
129- }
130- const [ user , password ] = auth . split ( ':' ) ;
131- if ( user === '' && password === '' ) {
228+ // Validate port
229+ if ( options . require_port && ! parsedUrl . port ) {
230+ return false ;
231+ }
232+ if ( parsedUrl . port ) {
233+ const port = parseInt ( parsedUrl . port , 10 ) ;
234+ if ( port <= 0 || port > 65535 ) {
132235 return false ;
133236 }
134237 }
135- hostname = split . join ( '@' ) ;
136238
137- port_str = null ;
138- ipv6 = null ;
139- const ipv6_match = hostname . match ( wrapped_ipv6 ) ;
140- if ( ipv6_match ) {
141- host = '' ;
142- ipv6 = ipv6_match [ 1 ] ;
143- port_str = ipv6_match [ 2 ] || null ;
144- } else {
145- split = hostname . split ( ':' ) ;
146- host = split . shift ( ) ;
147- if ( split . length ) {
148- port_str = split . join ( ':' ) ;
239+ // Validate authentication
240+ if ( options . disallow_auth && ( parsedUrl . username || parsedUrl . password ) ) {
241+ return false ;
242+ }
243+
244+ // Additional auth validation for security (multiple colons check)
245+ if ( parsedUrl . username !== '' || parsedUrl . password !== '' ) {
246+ // Check the original URL for multiple colons in auth part
247+ const authMatch = originalUrl . match ( / @ ( [ ^ / ] + ) / ) ;
248+ if ( authMatch ) {
249+ const beforeAuth = originalUrl . substring (
250+ 0 ,
251+ originalUrl . indexOf ( authMatch [ 0 ] )
252+ ) ;
253+ const authPart = beforeAuth . split ( '://' ) . pop ( ) || beforeAuth ;
254+ if ( authPart . split ( ':' ) . length > 2 ) {
255+ return false ;
256+ }
149257 }
150258 }
151259
152- if ( port_str !== null && port_str . length > 0 ) {
153- port = parseInt ( port_str , 10 ) ;
154- if ( ! / ^ [ 0 - 9 ] + $ / . test ( port_str ) || port <= 0 || port > 65535 ) {
260+ // Reject URLs with empty auth components like @example .com, :@example.com, or http://@example.com
261+ const emptyAuthMatch = originalUrl . match ( / ^ ( @ | : @ | \/ \/ @ [ ^ / ] | \/ \/ : @ ) / ) ;
262+ if ( emptyAuthMatch ) {
263+ return false ;
264+ }
265+
266+ // Also check for empty username in parsed URL (handles http://@example.com)
267+ // But allow empty username if there's a password (http://:pass@example.com)
268+ if (
269+ parsedUrl . username === '' &&
270+ parsedUrl . password === '' &&
271+ originalUrl . includes ( '@' ) &&
272+ ! originalUrl . match ( / ^ [ ^ : ] + : @ / )
273+ ) {
274+ return false ;
275+ }
276+
277+ // Security check: Reject URLs where username looks like a domain (phishing protection)
278+ // e.g., http://evil-site.com@example.com should be rejected
279+ if ( parsedUrl . username && parsedUrl . username . includes ( '.' ) ) {
280+ // Check if username looks like a domain (has common TLD patterns)
281+ const usernamePattern = / ^ [ a - z A - Z 0 - 9 . - ] + \. [ a - z A - Z ] { 2 , } $ / ;
282+ if ( usernamePattern . test ( parsedUrl . username ) ) {
155283 return false ;
156284 }
157- } else if ( options . require_port ) {
158- return false ;
159285 }
160286
161- if ( options . host_whitelist ) {
162- return checkHost ( host , options . host_whitelist ) ;
287+ let hostname = parsedUrl . hostname ;
288+
289+ // Special handling for URLs with empty hostnames but paths (like postgres://user:pw@/test)
290+ if ( ! hostname && originalUrl . includes ( '@/' ) && hasProtocol ) {
291+ // This is likely a database URL with empty hostname but a path
292+ return ! options . require_host ;
163293 }
164294
165- if ( host === '' && ! options . require_host ) {
166- return true ;
295+ // Handle IPv6 addresses
296+ let isIPv6 = false ;
297+ if ( hostname && hostname . startsWith ( '[' ) && hostname . endsWith ( ']' ) ) {
298+ const ipv6Address = hostname . slice ( 1 , - 1 ) ;
299+ if ( ! isIP ( ipv6Address , 6 ) ) {
300+ return false ;
301+ }
302+ isIPv6 = true ;
303+ hostname = ipv6Address ;
167304 }
168305
169- if ( ! isIP ( host ) && ! isFQDN ( host , options ) && ( ! ipv6 || ! isIP ( ipv6 , 6 ) ) ) {
306+ // Validate host whitelist/blacklist
307+ if ( hostname && options . host_whitelist ) {
308+ return checkHost ( hostname , options . host_whitelist ) ;
309+ }
310+
311+ if (
312+ hostname &&
313+ options . host_blacklist &&
314+ checkHost ( hostname , options . host_blacklist )
315+ ) {
170316 return false ;
171317 }
172318
173- host = host || ipv6 ;
319+ // Validate host format
320+ if ( hostname && ! isIPv6 ) {
321+ if ( isIP ( hostname ) ) {
322+ // IPv4 address is valid
323+ } else {
324+ // Validate as FQDN
325+ const fqdnOptions = {
326+ require_tld : options . require_tld ,
327+ allow_underscores : options . allow_underscores ,
328+ allow_trailing_dot : options . allow_trailing_dot ,
329+ } ;
174330
175- if ( options . host_blacklist && checkHost ( host , options . host_blacklist ) ) {
176- return false ;
331+ if ( ! isFQDN ( hostname , fqdnOptions ) ) {
332+ return false ;
333+ }
334+ }
177335 }
178336
179337 return true ;
0 commit comments