Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, 20, 18, 16, 14, 12, 10, 8, 6]
node-version: [22, 20, 18, 16, 14, 12, 10, 8]
name: Run tests on Node.js ${{ matrix.node-version }}
steps:
- name: Setup Node.js ${{ matrix.node-version }}
Expand Down
301 changes: 238 additions & 63 deletions src/lib/isURL.js
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';
Expand Down Expand Up @@ -34,7 +35,6 @@
*/


const default_url_options = {
protocols: ['http', 'https', 'ftp'],
require_tld: true,
Expand All @@ -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)) {
Expand All @@ -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) {
Expand All @@ -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 &&

Check failure

Code scanning / CodeQL

Incorrect suffix check High

This suffix check is missing a length comparison to correctly handle indexOf returning -1.

Copilot Autofix

AI 16 days ago

To fix the problem in line 222, ensure that the result of indexOf('://') is not -1 before making a position comparison. The best practice would be:

  • Store originalUrl.indexOf('://') in a variable (e.g., protoIdx).
  • Check that protoIdx !== -1 && protoIdx === originalUrl.length - 3

This avoids the case where both sides are -1 due 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.


Suggested changeset 1
src/lib/isURL.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/lib/isURL.js b/src/lib/isURL.js
--- a/src/lib/isURL.js
+++ b/src/lib/isURL.js
@@ -216,10 +216,12 @@
 
   // Handle special case for URLs ending with just protocol:// (should always fail)
   // But allow URLs like file:/// that have paths
+  const protoIdx = originalUrl.indexOf('://');
   if (
     !parsedUrl.hostname &&
     hasProtocol &&
-    originalUrl.indexOf('://') === originalUrl.length - 3 &&
+    protoIdx !== -1 &&
+    protoIdx === originalUrl.length - 3 &&
     (!parsedUrl.pathname || parsedUrl.pathname === '/')
   ) {
     return false;
EOF
@@ -216,10 +216,12 @@

// Handle special case for URLs ending with just protocol:// (should always fail)
// But allow URLs like file:/// that have paths
const protoIdx = originalUrl.indexOf('://');
if (
!parsedUrl.hostname &&
hasProtocol &&
originalUrl.indexOf('://') === originalUrl.length - 3 &&
protoIdx !== -1 &&
protoIdx === originalUrl.length - 3 &&
(!parsedUrl.pathname || parsedUrl.pathname === '/')
) {
return false;
Copilot is powered by AI and may make mistakes. Always verify output.
(!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;
Expand Down
Loading
Loading