-
-
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
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 3 months 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.