- 
          
- 
                Notifications
    You must be signed in to change notification settings 
- Fork 2.4k
fix(isURL): fix CVE-2025-56200 #2610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
          
     Closed
      
      
    
      
        
          +744
        
        
          −551
        
        
          
        
      
    
  
  
     Closed
                    Changes from all commits
      Commits
    
    
            Show all changes
          
          
            10 commits
          
        
        Select commit
          Hold shift + click to select a range
      
      87a5cde
              
                fix(isURL): fix CVE-2025-56200
              
              
                WikiRik 2e08bb2
              
                ci: remove Node 6
              
              
                WikiRik 6aed799
              
                test(isURL): split isURL tests to separate file
              
              
                WikiRik 4521994
              
                feat(isURL): rewrite isURL with native URL constructor
              
              
                WikiRik cf66832
              
                ci: update rollup and split build/test ci jobs
              
              
                WikiRik 8347fdc
              
                chore: fix typo
              
              
                WikiRik f65e2e4
              
                ci: undo CI changes
              
              
                WikiRik 57f5a0b
              
                chore: undo build changes
              
              
                WikiRik 69c2aad
              
                fix: add back Node 8 compatibility
              
              
                WikiRik 6e92526
              
                chore: fix lint
              
              
                WikiRik File filter
Filter by extension
Conversations
          Failed to load comments.   
        
        
          
      Loading
        
  Jump to
        
          Jump to file
        
      
      
          Failed to load files.   
        
        
          
      Loading
        
  Diff view
Diff view
There are no files selected for viewing
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              
              
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import assertString from './util/assertString'; | ||
| import checkHost from './util/checkHost'; | ||
| import includes from './util/includesString'; | ||
| import includesArray from './util/includesArray'; | ||
|  | ||
| import isFQDN from './isFQDN'; | ||
| import isIP from './isIP'; | ||
|  | @@ -34,7 +35,6 @@ | |
| */ | ||
|  | ||
|  | ||
| const default_url_options = { | ||
| protocols: ['http', 'https', 'ftp'], | ||
| require_tld: true, | ||
|  | @@ -51,8 +51,6 @@ | |
| max_allowed_length: 2084, | ||
| }; | ||
|  | ||
| const wrapped_ipv6 = /^\[([^\]]+)\](?::([0-9]+))?$/; | ||
|  | ||
| export default function isURL(url, options) { | ||
| assertString(url); | ||
| if (!url || /[\s<>]/.test(url)) { | ||
|  | @@ -61,6 +59,25 @@ | |
| if (url.indexOf('mailto:') === 0) { | ||
| return false; | ||
| } | ||
|  | ||
| // Security check: Reject URLs with Unicode characters that could be dangerous protocol spoofs | ||
| // Convert full-width Unicode to ASCII and check for dangerous protocols | ||
| const normalizedUrl = url.replace(/[\uFF00-\uFFEF]/g, (char) => { | ||
| const code = char.charCodeAt(0); | ||
| if (code >= 0xff01 && code <= 0xff5e) { | ||
| return String.fromCharCode(code - 0xfee0); | ||
| } | ||
| return char; | ||
| }); | ||
|  | ||
| /* eslint-disable no-script-url */ | ||
| const dangerousProtocolPrefixes = ['javascript:', 'data:', 'vbscript:']; | ||
| /* eslint-enable no-script-url */ | ||
| if ( | ||
| dangerousProtocolPrefixes.some(protocol => normalizedUrl.toLowerCase().indexOf(protocol) === 0) | ||
| ) { | ||
| return false; | ||
| } | ||
| options = merge(options, default_url_options); | ||
|  | ||
| if (options.validate_length && url.length > options.max_allowed_length) { | ||
|  | @@ -71,104 +88,262 @@ | |
| return false; | ||
| } | ||
|  | ||
| if (!options.allow_query_components && (includes(url, '?') || includes(url, '&'))) { | ||
| if ( | ||
| !options.allow_query_components && | ||
| (includes(url, '?') || includes(url, '&')) | ||
| ) { | ||
| return false; | ||
| } | ||
|  | ||
| let protocol, auth, host, hostname, port, port_str, split, ipv6; | ||
| let originalUrl = url; | ||
| let hasProtocol = false; | ||
| let isProtocolRelative = false; | ||
|  | ||
| // Check for multiple slashes like ////foobar.com or http:////foobar.com | ||
| // But allow file:/// which is a valid file URL pattern | ||
| if ( | ||
| url.indexOf('///') === 0 || | ||
| (originalUrl.match(/:\/\/\/\/+/) && originalUrl.indexOf('file:///') !== 0) | ||
| ) { | ||
| return false; | ||
| } | ||
|  | ||
| // Check for protocol-relative URLs (must start with exactly //) | ||
| if (url.indexOf('//') === 0 && url.indexOf('///') !== 0) { | ||
| if (!options.allow_protocol_relative_urls) { | ||
| return false; | ||
| } | ||
| isProtocolRelative = true; | ||
| hasProtocol = true; | ||
| url = `http:${url}`; // Temporarily add protocol for parsing | ||
| } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { | ||
| // Only check for auth-like patterns if there's no :// in the URL (not a real protocol) | ||
| if (!includes(originalUrl, '://')) { | ||
| // Special case: check if this looks like auth info rather than a protocol | ||
| // Pattern: word:something@domain (but not common protocols) | ||
| const authLikeMatch = originalUrl.match(/^([^:/@]+):([^@]*@[^/]+)/); | ||
| if (authLikeMatch) { | ||
| const possibleProtocol = authLikeMatch[1].toLowerCase(); | ||
|  | ||
| // Normalize Unicode full-width characters to ASCII for security check | ||
| const normalizedProtocol = possibleProtocol.replace( | ||
| /[\uFF00-\uFFEF]/g, | ||
| (char) => { | ||
| const code = char.charCodeAt(0); | ||
| // Convert full-width ASCII to regular ASCII | ||
| if (code >= 0xff01 && code <= 0xff5e) { | ||
| return String.fromCharCode(code - 0xfee0); | ||
| } | ||
| return char; | ||
| } | ||
| ); | ||
|  | ||
| split = url.split('#'); | ||
| url = split.shift(); | ||
| const knownDangerousProtocols = ['javascript', 'data', 'vbscript']; | ||
|  | ||
| split = url.split('?'); | ||
| url = split.shift(); | ||
| if ( | ||
| !includesArray(knownDangerousProtocols, possibleProtocol) && | ||
| !includesArray(knownDangerousProtocols, normalizedProtocol) | ||
| ) { | ||
| // This looks like auth info, treat as no protocol | ||
| hasProtocol = false; // Important: mark as no protocol since we're adding one | ||
| url = `http://${url}`; | ||
| } else { | ||
| hasProtocol = true; | ||
| // This is a dangerous protocol in auth component (CVE-2025-56200) | ||
| return false; | ||
| } | ||
| } else { | ||
| hasProtocol = true; | ||
| } | ||
| } else { | ||
| hasProtocol = true; | ||
| } | ||
| } else { | ||
| // Single slash should not be treated as protocol-relative | ||
| if (url.indexOf('/') === 0 && url.indexOf('//') !== 0) { | ||
| return false; | ||
| } | ||
|  | ||
| split = url.split('://'); | ||
| if (split.length > 1) { | ||
| protocol = split.shift().toLowerCase(); | ||
| if (options.require_valid_protocol && options.protocols.indexOf(protocol) === -1) { | ||
| // No protocol, add a temporary one for parsing | ||
| url = `http://${url}`; | ||
| } | ||
|  | ||
| let parsedUrl; | ||
|  | ||
| // Special handling for database URLs like postgres://user:pw@/test | ||
| if ( | ||
| originalUrl.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^@\/]+@\//) && | ||
| !options.require_host | ||
| ) { | ||
| // This is a database URL with empty hostname but auth and path | ||
| try { | ||
| // Replace @/ with @localhost/ temporarily for parsing | ||
| const tempUrl = url.replace('@/', '@localhost/'); | ||
| parsedUrl = new URL(tempUrl); | ||
| // Clear the hostname since it was fake | ||
| Object.defineProperty(parsedUrl, 'hostname', { | ||
| value: '', | ||
| writable: false, | ||
| }); | ||
| Object.defineProperty(parsedUrl, 'host', { value: '', writable: false }); | ||
| } catch (e) { | ||
| return false; | ||
| } | ||
| } else if (options.require_protocol) { | ||
| return false; | ||
| } else if (url.slice(0, 2) === '//') { | ||
| if (!options.allow_protocol_relative_urls) { | ||
| } else { | ||
| // Use native URL constructor for parsing | ||
| try { | ||
| parsedUrl = new URL(url); | ||
| } catch (e) { | ||
| return false; | ||
| } | ||
| split[0] = url.slice(2); | ||
| } | ||
| url = split.join('://'); | ||
|  | ||
| if (url === '') { | ||
| // Validate protocol | ||
| const protocol = parsedUrl.protocol.slice(0, -1); // Remove trailing ':' | ||
| if ( | ||
| hasProtocol && | ||
| options.require_valid_protocol && | ||
| !includesArray(options.protocols, protocol) | ||
| ) { | ||
| return false; | ||
| } | ||
| if (!hasProtocol && options.require_protocol) { | ||
| return false; | ||
| } | ||
| if (isProtocolRelative && options.require_protocol) { | ||
| return false; | ||
| } | ||
|  | ||
| split = url.split('/'); | ||
| url = split.shift(); | ||
| // Handle special case for URLs ending with just protocol:// (should always fail) | ||
| // But allow URLs like file:/// that have paths | ||
| if ( | ||
| !parsedUrl.hostname && | ||
| hasProtocol && | ||
| originalUrl.indexOf('://') === originalUrl.length - 3 && | ||
| (!parsedUrl.pathname || parsedUrl.pathname === '/') | ||
| ) { | ||
| return false; | ||
| } | ||
|  | ||
| if (url === '' && !options.require_host) { | ||
| // Validate host presence | ||
| if (!parsedUrl.hostname && options.require_host) { | ||
| return false; | ||
| } | ||
| if (!parsedUrl.hostname && !options.require_host) { | ||
| return true; | ||
| } | ||
|  | ||
| split = url.split('@'); | ||
| if (split.length > 1) { | ||
| if (options.disallow_auth) { | ||
| return false; | ||
| } | ||
| if (split[0] === '') { | ||
| return false; | ||
| } | ||
| auth = split.shift(); | ||
| if (auth.indexOf(':') >= 0 && auth.split(':').length > 2) { | ||
| return false; | ||
| } | ||
| const [user, password] = auth.split(':'); | ||
| if (user === '' && password === '') { | ||
| // Validate port | ||
| if (options.require_port && !parsedUrl.port) { | ||
| return false; | ||
| } | ||
| if (parsedUrl.port) { | ||
| const port = parseInt(parsedUrl.port, 10); | ||
| if (port <= 0 || port > 65535) { | ||
| return false; | ||
| } | ||
| } | ||
| hostname = split.join('@'); | ||
|  | ||
| port_str = null; | ||
| ipv6 = null; | ||
| const ipv6_match = hostname.match(wrapped_ipv6); | ||
| if (ipv6_match) { | ||
| host = ''; | ||
| ipv6 = ipv6_match[1]; | ||
| port_str = ipv6_match[2] || null; | ||
| } else { | ||
| split = hostname.split(':'); | ||
| host = split.shift(); | ||
| if (split.length) { | ||
| port_str = split.join(':'); | ||
| // Validate authentication | ||
| if (options.disallow_auth && (parsedUrl.username || parsedUrl.password)) { | ||
| return false; | ||
| } | ||
|  | ||
| // Additional auth validation for security (multiple colons check) | ||
| if (parsedUrl.username !== '' || parsedUrl.password !== '') { | ||
| // Check the original URL for multiple colons in auth part | ||
| const authMatch = originalUrl.match(/@([^/]+)/); | ||
| if (authMatch) { | ||
| const beforeAuth = originalUrl.substring( | ||
| 0, | ||
| originalUrl.indexOf(authMatch[0]) | ||
| ); | ||
| const authPart = beforeAuth.split('://').pop() || beforeAuth; | ||
| if (authPart.split(':').length > 2) { | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
|  | ||
| if (port_str !== null && port_str.length > 0) { | ||
| port = parseInt(port_str, 10); | ||
| if (!/^[0-9]+$/.test(port_str) || port <= 0 || port > 65535) { | ||
| // Reject URLs with empty auth components like @example.com, :@example.com, or http://@example.com | ||
| const emptyAuthMatch = originalUrl.match(/^(@|:@|\/\/@[^/]|\/\/:@)/); | ||
| if (emptyAuthMatch) { | ||
| return false; | ||
| } | ||
|  | ||
| // Also check for empty username in parsed URL (handles http://@example.com) | ||
| // But allow empty username if there's a password (http://:[email protected]) | ||
| if ( | ||
| parsedUrl.username === '' && | ||
| parsedUrl.password === '' && | ||
| includes(originalUrl, '@') && | ||
| !originalUrl.match(/^[^:]+:@/) | ||
| ) { | ||
| return false; | ||
| } | ||
|  | ||
| // Security check: Reject URLs where username looks like a domain (phishing protection) | ||
| // e.g., http://[email protected] should be rejected | ||
| if (parsedUrl.username && includes(parsedUrl.username, '.')) { | ||
| // Check if username looks like a domain (has common TLD patterns) | ||
| const usernamePattern = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; | ||
| if (usernamePattern.test(parsedUrl.username)) { | ||
| return false; | ||
| } | ||
| } else if (options.require_port) { | ||
| return false; | ||
| } | ||
|  | ||
| if (options.host_whitelist) { | ||
| return checkHost(host, options.host_whitelist); | ||
| let hostname = parsedUrl.hostname; | ||
|  | ||
| // Special handling for URLs with empty hostnames but paths (like postgres://user:pw@/test) | ||
| if (!hostname && includes(originalUrl, '@/') && hasProtocol) { | ||
| // This is likely a database URL with empty hostname but a path | ||
| return !options.require_host; | ||
| } | ||
|  | ||
| if (host === '' && !options.require_host) { | ||
| return true; | ||
| // Handle IPv6 addresses | ||
| let isIPv6 = false; | ||
| if ( | ||
| hostname && | ||
| hostname.indexOf('[') === 0 && | ||
| hostname.indexOf(']') === hostname.length - 1 | ||
| ) { | ||
| const ipv6Address = hostname.slice(1, -1); | ||
| if (!isIP(ipv6Address, 6)) { | ||
| return false; | ||
| } | ||
| isIPv6 = true; | ||
| hostname = ipv6Address; | ||
| } | ||
|  | ||
| if (!isIP(host) && !isFQDN(host, options) && (!ipv6 || !isIP(ipv6, 6))) { | ||
| // Validate host whitelist/blacklist | ||
| if (hostname && options.host_whitelist) { | ||
| return checkHost(hostname, options.host_whitelist); | ||
| } | ||
|  | ||
| if ( | ||
| hostname && | ||
| options.host_blacklist && | ||
| checkHost(hostname, options.host_blacklist) | ||
| ) { | ||
| return false; | ||
| } | ||
|  | ||
| host = host || ipv6; | ||
| // Validate host format | ||
| if (hostname && !isIPv6) { | ||
| if (isIP(hostname)) { | ||
| // IPv4 address is valid | ||
| } else { | ||
| // Validate as FQDN | ||
| const fqdnOptions = { | ||
| require_tld: options.require_tld, | ||
| allow_underscores: options.allow_underscores, | ||
| allow_trailing_dot: options.allow_trailing_dot, | ||
| }; | ||
|  | ||
| if (options.host_blacklist && checkHost(host, options.host_blacklist)) { | ||
| return false; | ||
| if (!isFQDN(hostname, fqdnOptions)) { | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
|  | ||
| return true; | ||
|  | ||
      
      Oops, something went wrong.
        
    
  
      
      Oops, something went wrong.
        
    
  
  Add this suggestion to a batch that can be applied as a single commit.
  This suggestion is invalid because no changes were made to the code.
  Suggestions cannot be applied while the pull request is closed.
  Suggestions cannot be applied while viewing a subset of changes.
  Only one suggestion per line can be applied in a batch.
  Add this suggestion to a batch that can be applied as a single commit.
  Applying suggestions on deleted lines is not supported.
  You must change the existing code in this line in order to create a valid suggestion.
  Outdated suggestions cannot be applied.
  This suggestion has been applied or marked resolved.
  Suggestions cannot be applied from pending reviews.
  Suggestions cannot be applied on multi-line comments.
  Suggestions cannot be applied while the pull request is queued to merge.
  Suggestion cannot be applied right now. Please check back later.
  
    
  
    
Check failure
Code scanning / CodeQL
Incorrect suffix check High
Copilot Autofix
AI 16 days ago
To fix the problem in line 222, ensure that the result of
indexOf('://')is not-1before making a position comparison. The best practice would be:originalUrl.indexOf('://')in a variable (e.g.,protoIdx).protoIdx !== -1 && protoIdx === originalUrl.length - 3This avoids the case where both sides are
-1due to missing substring, and thus avoids a false positive. Restrict the change to just the affected region around line 222.No new methods or complex imports are needed—just a minor code change to add the variable and condition.