diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af090a9be..e0024eff3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/src/lib/isURL.js b/src/lib/isURL.js index 0fec384ba..b25592fcb 100644 --- a/src/lib/isURL.js +++ b/src/lib/isURL.js @@ -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 @@ max_allowed_length - if set, isURL will not allow URLs longer than the specified */ - const default_url_options = { protocols: ['http', 'https', 'ftp'], require_tld: true, @@ -51,8 +51,6 @@ const default_url_options = { 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 @@ export default function isURL(url, options) { 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 @@ export default function isURL(url, options) { 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://:pass@example.com) + 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://evil-site.com@example.com 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; diff --git a/test/validators.test.js b/test/validators.test.js index 12c5fc2ab..68d0d6186 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -377,493 +377,6 @@ describe('Validators', () => { }); }); - it('should validate URLs', () => { - test({ - validator: 'isURL', - valid: [ - 'foobar.com', - 'www.foobar.com', - 'foobar.com/', - 'valid.au', - 'http://www.foobar.com/', - 'HTTP://WWW.FOOBAR.COM/', - 'https://www.foobar.com/', - 'HTTPS://WWW.FOOBAR.COM/', - 'http://www.foobar.com:23/', - 'http://www.foobar.com:65535/', - 'http://www.foobar.com:5/', - 'https://www.foobar.com/', - 'ftp://www.foobar.com/', - 'http://www.foobar.com/~foobar', - 'http://user:pass@www.foobar.com/', - 'http://user:@www.foobar.com/', - 'http://:pass@www.foobar.com/', - 'http://user@www.foobar.com', - 'http://127.0.0.1/', - 'http://10.0.0.0/', - 'http://189.123.14.13/', - 'http://duckduckgo.com/?q=%2F', - 'http://foobar.com/t$-_.+!*\'(),', - 'http://foobar.com/?foo=bar#baz=qux', - 'http://foobar.com?foo=bar', - 'http://foobar.com#baz=qux', - 'http://www.xn--froschgrn-x9a.net/', - 'http://xn--froschgrn-x9a.com/', - 'http://foo--bar.com', - 'http://høyfjellet.no', - 'http://xn--j1aac5a4g.xn--j1amh', - 'http://xn------eddceddeftq7bvv7c4ke4c.xn--p1ai', - 'http://кулік.укр', - 'test.com?ref=http://test2.com', - 'http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html', - 'http://[1080:0:0:0:8:800:200C:417A]/index.html', - 'http://[3ffe:2a00:100:7031::1]', - 'http://[1080::8:800:200C:417A]/foo', - 'http://[::192.9.5.5]/ipng', - 'http://[::FFFF:129.144.52.38]:80/index.html', - 'http://[2010:836B:4179::836B:4179]', - 'http://example.com/example.json#/foo/bar', - 'http://1337.com', - ], - invalid: [ - 'http://localhost:3000/', - '//foobar.com', - 'xyz://foobar.com', - 'invalid/', - 'invalid.x', - 'invalid.', - '.com', - 'http://com/', - 'http://300.0.0.1/', - 'mailto:foo@bar.com', - 'rtmp://foobar.com', - 'http://www.xn--.com/', - 'http://xn--.com/', - 'http://www.foobar.com:0/', - 'http://www.foobar.com:70000/', - 'http://www.foobar.com:99999/', - 'http://www.-foobar.com/', - 'http://www.foobar-.com/', - 'http://foobar/# lol', - 'http://foobar/? lol', - 'http://foobar/ lol/', - 'http://lol @foobar.com/', - 'http://lol:lol @foobar.com/', - 'http://lol:lol:lol@foobar.com/', - 'http://lol: @foobar.com/', - 'http://www.foo_bar.com/', - 'http://www.foobar.com/\t', - 'http://@foobar.com', - 'http://:@foobar.com', - 'http://\n@www.foobar.com/', - '', - `http://foobar.com/${new Array(2083).join('f')}`, - 'http://*.foo.com', - '*.foo.com', - '!.foo.com', - 'http://example.com.', - 'http://localhost:61500this is an invalid url!!!!', - '////foobar.com', - 'http:////foobar.com', - 'https://example.com/foo//', - ], - }); - }); - - it('should validate URLs with custom protocols', () => { - test({ - validator: 'isURL', - args: [{ - protocols: ['rtmp'], - }], - valid: [ - 'rtmp://foobar.com', - ], - invalid: [ - 'http://foobar.com', - ], - }); - }); - - it('should validate file URLs without a host', () => { - test({ - validator: 'isURL', - args: [{ - protocols: ['file'], - require_host: false, - require_tld: false, - }], - valid: [ - 'file://localhost/foo.txt', - 'file:///foo.txt', - 'file:///', - ], - invalid: [ - 'http://foobar.com', - 'file://', - ], - }); - }); - - it('should validate postgres URLs without a host', () => { - test({ - validator: 'isURL', - args: [{ - protocols: ['postgres'], - require_host: false, - }], - valid: [ - 'postgres://user:pw@/test', - ], - invalid: [ - 'http://foobar.com', - 'postgres://', - ], - }); - }); - - - it('should validate URLs with any protocol', () => { - test({ - validator: 'isURL', - args: [{ - require_valid_protocol: false, - }], - valid: [ - 'rtmp://foobar.com', - 'http://foobar.com', - 'test://foobar.com', - ], - invalid: [ - 'mailto:test@example.com', - ], - }); - }); - - it('should validate URLs with underscores', () => { - test({ - validator: 'isURL', - args: [{ - allow_underscores: true, - }], - valid: [ - 'http://foo_bar.com', - 'http://pr.example_com.294.example.com/', - 'http://foo__bar.com', - 'http://_.example.com', - ], - invalid: [], - }); - }); - - it('should validate URLs that do not have a TLD', () => { - test({ - validator: 'isURL', - args: [{ - require_tld: false, - }], - valid: [ - 'http://foobar.com/', - 'http://foobar/', - 'http://localhost/', - 'foobar/', - 'foobar', - ], - invalid: [], - }); - }); - - it('should validate URLs with a trailing dot option', () => { - test({ - validator: 'isURL', - args: [{ - allow_trailing_dot: true, - require_tld: false, - }], - valid: [ - 'http://example.com.', - 'foobar.', - ], - }); - }); - - it('should validate URLs with column and no port', () => { - test({ - validator: 'isURL', - valid: [ - 'http://example.com:', - 'ftp://example.com:', - ], - invalid: [ - 'https://example.com:abc', - ], - }); - }); - - it('should validate sftp protocol URL containing column and no port', () => { - test({ - validator: 'isURL', - args: [{ - protocols: ['sftp'], - }], - valid: [ - 'sftp://user:pass@terminal.aws.test.nl:/incoming/things.csv', - ], - }); - }); - - it('should validate protocol relative URLs', () => { - test({ - validator: 'isURL', - args: [{ - allow_protocol_relative_urls: true, - }], - valid: [ - '//foobar.com', - 'http://foobar.com', - 'foobar.com', - ], - invalid: [ - '://foobar.com', - '/foobar.com', - '////foobar.com', - 'http:////foobar.com', - ], - }); - }); - - it('should not validate URLs with fragments when allow fragments is false', () => { - test({ - validator: 'isURL', - args: [{ - allow_fragments: false, - }], - valid: [ - 'http://foobar.com', - 'foobar.com', - ], - invalid: [ - 'http://foobar.com#part', - 'foobar.com#part', - ], - }); - }); - - it('should not validate URLs with query components when allow query components is false', () => { - test({ - validator: 'isURL', - args: [{ - allow_query_components: false, - }], - valid: [ - 'http://foobar.com', - 'foobar.com', - ], - invalid: [ - 'http://foobar.com?foo=bar', - 'http://foobar.com?foo=bar&bar=foo', - 'foobar.com?foo=bar', - 'foobar.com?foo=bar&bar=foo', - ], - }); - }); - - it('should not validate protocol relative URLs when require protocol is true', () => { - test({ - validator: 'isURL', - args: [{ - allow_protocol_relative_urls: true, - require_protocol: true, - }], - valid: [ - 'http://foobar.com', - ], - invalid: [ - '//foobar.com', - '://foobar.com', - '/foobar.com', - 'foobar.com', - ], - }); - }); - - it('should let users specify whether URLs require a protocol', () => { - test({ - validator: 'isURL', - args: [{ - require_protocol: true, - }], - valid: [ - 'http://foobar.com/', - ], - invalid: [ - 'http://localhost/', - 'foobar.com', - 'foobar', - ], - }); - }); - - it('should let users specify a host whitelist', () => { - test({ - validator: 'isURL', - args: [{ - host_whitelist: ['foo.com', 'bar.com'], - }], - valid: [ - 'http://bar.com/', - 'http://foo.com/', - ], - invalid: [ - 'http://foobar.com', - 'http://foo.bar.com/', - 'http://qux.com', - ], - }); - }); - - it('should allow regular expressions in the host whitelist', () => { - test({ - validator: 'isURL', - args: [{ - host_whitelist: ['bar.com', 'foo.com', /\.foo\.com$/], - }], - valid: [ - 'http://bar.com/', - 'http://foo.com/', - 'http://images.foo.com/', - 'http://cdn.foo.com/', - 'http://a.b.c.foo.com/', - ], - invalid: [ - 'http://foobar.com', - 'http://foo.bar.com/', - 'http://qux.com', - ], - }); - }); - - it('should let users specify a host blacklist', () => { - test({ - validator: 'isURL', - args: [{ - host_blacklist: ['foo.com', 'bar.com'], - }], - valid: [ - 'http://foobar.com', - 'http://foo.bar.com/', - 'http://qux.com', - ], - invalid: [ - 'http://bar.com/', - 'http://foo.com/', - ], - }); - }); - - it('should allow regular expressions in the host blacklist', () => { - test({ - validator: 'isURL', - args: [{ - host_blacklist: ['bar.com', 'foo.com', /\.foo\.com$/], - }], - valid: [ - 'http://foobar.com', - 'http://foo.bar.com/', - 'http://qux.com', - ], - invalid: [ - 'http://bar.com/', - 'http://foo.com/', - 'http://images.foo.com/', - 'http://cdn.foo.com/', - 'http://a.b.c.foo.com/', - ], - }); - }); - - it('should allow rejecting urls containing authentication information', () => { - test({ - validator: 'isURL', - args: [{ disallow_auth: true }], - valid: [ - 'doe.com', - ], - invalid: [ - 'john@doe.com', - 'john:john@doe.com', - ], - }); - }); - - it('should accept urls containing authentication information', () => { - test({ - validator: 'isURL', - args: [{ disallow_auth: false }], - valid: [ - 'user@example.com', - 'user:@example.com', - 'user:password@example.com', - ], - invalid: [ - 'user:user:password@example.com', - '@example.com', - ':@example.com', - ':example.com', - ], - }); - }); - - it('should allow user to skip URL length validation', () => { - test({ - validator: 'isURL', - args: [{ validate_length: false }], - valid: [ - 'http://foobar.com/f', - `http://foobar.com/${new Array(2083).join('f')}`, - ], - invalid: [], - }); - }); - - it('should allow user to configure the maximum URL length', () => { - test({ - validator: 'isURL', - args: [{ max_allowed_length: 20 }], - valid: [ - 'http://foobar.com/12', // 20 characters - 'http://foobar.com/', - ], - invalid: [ - 'http://foobar.com/123', // 21 characters - 'http://foobar.com/1234567890', - ], - }); - }); - - it('should validate URLs with port present', () => { - test({ - validator: 'isURL', - args: [{ require_port: true }], - valid: [ - 'http://user:pass@www.foobar.com:1', - 'http://user:@www.foobar.com:65535', - 'http://127.0.0.1:23', - 'http://10.0.0.0:256', - 'http://189.123.14.13:256', - 'http://duckduckgo.com:65535?q=%2F', - ], - invalid: [ - 'http://user:pass@www.foobar.com/', - 'http://user:@www.foobar.com/', - 'http://127.0.0.1/', - 'http://10.0.0.0/', - 'http://189.123.14.13/', - 'http://duckduckgo.com/?q=%2F', - ], - }); - }); - it('should validate MAC addresses', () => { test({ validator: 'isMACAddress', diff --git a/test/validators/isURL.test.js b/test/validators/isURL.test.js new file mode 100644 index 000000000..ff09dee7f --- /dev/null +++ b/test/validators/isURL.test.js @@ -0,0 +1,505 @@ +import test from '../testFunctions'; + +describe('isURL', () => { + it('should validate URLs', () => { + test({ + validator: 'isURL', + valid: [ + 'foobar.com', + 'www.foobar.com', + 'foobar.com/', + 'valid.au', + 'http://www.foobar.com/', + 'HTTP://WWW.FOOBAR.COM/', + 'https://www.foobar.com/', + 'HTTPS://WWW.FOOBAR.COM/', + 'http://www.foobar.com:23/', + 'http://www.foobar.com:65535/', + 'http://www.foobar.com:5/', + 'https://www.foobar.com/', + 'ftp://www.foobar.com/', + 'http://www.foobar.com/~foobar', + 'http://user:pass@www.foobar.com/', + 'http://user:@www.foobar.com/', + 'http://:pass@www.foobar.com/', + 'http://user@www.foobar.com', + 'http://127.0.0.1/', + 'http://10.0.0.0/', + 'http://189.123.14.13/', + 'http://duckduckgo.com/?q=%2F', + 'http://foobar.com/t$-_.+!*\'(),', + 'http://foobar.com/?foo=bar#baz=qux', + 'http://foobar.com?foo=bar', + 'http://foobar.com#baz=qux', + 'http://www.xn--froschgrn-x9a.net/', + 'http://xn--froschgrn-x9a.com/', + 'http://foo--bar.com', + 'http://høyfjellet.no', + 'http://xn--j1aac5a4g.xn--j1amh', + 'http://xn------eddceddeftq7bvv7c4ke4c.xn--p1ai', + 'http://кулік.укр', + 'test.com?ref=http://test2.com', + 'http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html', + 'http://[1080:0:0:0:8:800:200C:417A]/index.html', + 'http://[3ffe:2a00:100:7031::1]', + 'http://[1080::8:800:200C:417A]/foo', + 'http://[::192.9.5.5]/ipng', + 'http://[::FFFF:129.144.52.38]:80/index.html', + 'http://[2010:836B:4179::836B:4179]', + 'http://example.com/example.json#/foo/bar', + 'http://1337.com', + ], + invalid: [ + 'http://localhost:3000/', + '//foobar.com', + 'xyz://foobar.com', + 'invalid/', + 'invalid.x', + 'invalid.', + '.com', + 'http://com/', + 'http://300.0.0.1/', + 'mailto:foo@bar.com', + 'rtmp://foobar.com', + 'http://www.xn--.com/', + 'http://xn--.com/', + 'http://www.foobar.com:0/', + 'http://www.foobar.com:70000/', + 'http://www.foobar.com:99999/', + 'http://www.-foobar.com/', + 'http://www.foobar-.com/', + 'http://foobar/# lol', + 'http://foobar/? lol', + 'http://foobar/ lol/', + 'http://lol @foobar.com/', + 'http://lol:lol @foobar.com/', + 'http://lol:lol:lol@foobar.com/', + 'http://lol: @foobar.com/', + 'http://www.foo_bar.com/', + 'http://www.foobar.com/\t', + 'http://@foobar.com', + 'http://:@foobar.com', + 'http://\n@www.foobar.com/', + '', + `http://foobar.com/${new Array(2083).join('f')}`, + 'http://*.foo.com', + '*.foo.com', + '!.foo.com', + 'http://example.com.', + 'http://localhost:61500this is an invalid url!!!!', + '////foobar.com', + 'http:////foobar.com', + 'https://example.com/foo//', + // the following tests are because of CVE-2025-56200 + /* eslint-disable no-script-url */ + "javascript:alert(1);a=';@example.com/alert(1)'", + 'JaVaScRiPt:alert(1)@example.com', + 'javascript:%61%6c%65%72%74%28%31%29@example.com', + 'javascript:/* comment */alert(1)@example.com', + 'javascript:var a=1; alert(a);@example.com', + 'javascript:alert(1)@user@example.com', + 'javascript:alert(1)@example.com?q=safe', + 'data:text/html,@example.com', + 'vbscript:msgbox("XSS")@example.com', + '//evil-site.com/path@example.com', + 'http://evil-site.com@example.com', + 'javascript:alert(1)@example.com', + /* eslint-enable no-script-url */ + ], + }); + }); + + it('should validate URLs with custom protocols', () => { + test({ + validator: 'isURL', + args: [{ + protocols: ['rtmp'], + }], + valid: [ + 'rtmp://foobar.com', + ], + invalid: [ + 'http://foobar.com', + ], + }); + }); + + it('should validate file URLs without a host', () => { + test({ + validator: 'isURL', + args: [{ + protocols: ['file'], + require_host: false, + require_tld: false, + }], + valid: [ + 'file://localhost/foo.txt', + 'file:///foo.txt', + 'file:///', + ], + invalid: [ + 'http://foobar.com', + 'file://', + ], + }); + }); + + it('should validate postgres URLs without a host', () => { + test({ + validator: 'isURL', + args: [{ + protocols: ['postgres'], + require_host: false, + }], + valid: [ + 'postgres://user:pw@/test', + ], + invalid: [ + 'http://foobar.com', + 'postgres://', + ], + }); + }); + + + it('should validate URLs with any protocol', () => { + test({ + validator: 'isURL', + args: [{ + require_valid_protocol: false, + }], + valid: [ + 'rtmp://foobar.com', + 'http://foobar.com', + 'test://foobar.com', + ], + invalid: [ + 'mailto:test@example.com', + ], + }); + }); + + it('should validate URLs with underscores', () => { + test({ + validator: 'isURL', + args: [{ + allow_underscores: true, + }], + valid: [ + 'http://foo_bar.com', + 'http://pr.example_com.294.example.com/', + 'http://foo__bar.com', + 'http://_.example.com', + ], + invalid: [], + }); + }); + + it('should validate URLs that do not have a TLD', () => { + test({ + validator: 'isURL', + args: [{ + require_tld: false, + }], + valid: [ + 'http://foobar.com/', + 'http://foobar/', + 'http://localhost/', + 'foobar/', + 'foobar', + ], + invalid: [], + }); + }); + + it('should validate URLs with a trailing dot option', () => { + test({ + validator: 'isURL', + args: [{ + allow_trailing_dot: true, + require_tld: false, + }], + valid: [ + 'http://example.com.', + 'foobar.', + ], + }); + }); + + it('should validate URLs with column and no port', () => { + test({ + validator: 'isURL', + valid: [ + 'http://example.com:', + 'ftp://example.com:', + ], + invalid: [ + 'https://example.com:abc', + ], + }); + }); + + it('should validate sftp protocol URL containing column and no port', () => { + test({ + validator: 'isURL', + args: [{ + protocols: ['sftp'], + }], + valid: [ + 'sftp://user:pass@terminal.aws.test.nl:/incoming/things.csv', + ], + }); + }); + + it('should validate protocol relative URLs', () => { + test({ + validator: 'isURL', + args: [{ + allow_protocol_relative_urls: true, + }], + valid: [ + '//foobar.com', + 'http://foobar.com', + 'foobar.com', + ], + invalid: [ + '://foobar.com', + '/foobar.com', + '////foobar.com', + 'http:////foobar.com', + ], + }); + }); + + it('should not validate URLs with fragments when allow fragments is false', () => { + test({ + validator: 'isURL', + args: [{ + allow_fragments: false, + }], + valid: [ + 'http://foobar.com', + 'foobar.com', + ], + invalid: [ + 'http://foobar.com#part', + 'foobar.com#part', + ], + }); + }); + + it('should not validate URLs with query components when allow query components is false', () => { + test({ + validator: 'isURL', + args: [{ + allow_query_components: false, + }], + valid: [ + 'http://foobar.com', + 'foobar.com', + ], + invalid: [ + 'http://foobar.com?foo=bar', + 'http://foobar.com?foo=bar&bar=foo', + 'foobar.com?foo=bar', + 'foobar.com?foo=bar&bar=foo', + ], + }); + }); + + it('should not validate protocol relative URLs when require protocol is true', () => { + test({ + validator: 'isURL', + args: [{ + allow_protocol_relative_urls: true, + require_protocol: true, + }], + valid: [ + 'http://foobar.com', + ], + invalid: [ + '//foobar.com', + '://foobar.com', + '/foobar.com', + 'foobar.com', + ], + }); + }); + + it('should let users specify whether URLs require a protocol', () => { + test({ + validator: 'isURL', + args: [{ + require_protocol: true, + }], + valid: [ + 'http://foobar.com/', + ], + invalid: [ + 'http://localhost/', + 'foobar.com', + 'foobar', + ], + }); + }); + + it('should let users specify a host whitelist', () => { + test({ + validator: 'isURL', + args: [{ + host_whitelist: ['foo.com', 'bar.com'], + }], + valid: [ + 'http://bar.com/', + 'http://foo.com/', + ], + invalid: [ + 'http://foobar.com', + 'http://foo.bar.com/', + 'http://qux.com', + ], + }); + }); + + it('should allow regular expressions in the host whitelist', () => { + test({ + validator: 'isURL', + args: [{ + host_whitelist: ['bar.com', 'foo.com', /\.foo\.com$/], + }], + valid: [ + 'http://bar.com/', + 'http://foo.com/', + 'http://images.foo.com/', + 'http://cdn.foo.com/', + 'http://a.b.c.foo.com/', + ], + invalid: [ + 'http://foobar.com', + 'http://foo.bar.com/', + 'http://qux.com', + ], + }); + }); + + it('should let users specify a host blacklist', () => { + test({ + validator: 'isURL', + args: [{ + host_blacklist: ['foo.com', 'bar.com'], + }], + valid: [ + 'http://foobar.com', + 'http://foo.bar.com/', + 'http://qux.com', + ], + invalid: [ + 'http://bar.com/', + 'http://foo.com/', + ], + }); + }); + + it('should allow regular expressions in the host blacklist', () => { + test({ + validator: 'isURL', + args: [{ + host_blacklist: ['bar.com', 'foo.com', /\.foo\.com$/], + }], + valid: [ + 'http://foobar.com', + 'http://foo.bar.com/', + 'http://qux.com', + ], + invalid: [ + 'http://bar.com/', + 'http://foo.com/', + 'http://images.foo.com/', + 'http://cdn.foo.com/', + 'http://a.b.c.foo.com/', + ], + }); + }); + + it('should allow rejecting urls containing authentication information', () => { + test({ + validator: 'isURL', + args: [{ disallow_auth: true }], + valid: [ + 'doe.com', + ], + invalid: [ + 'john@doe.com', + 'john:john@doe.com', + ], + }); + }); + + it('should accept urls containing authentication information', () => { + test({ + validator: 'isURL', + args: [{ disallow_auth: false }], + valid: [ + 'user@example.com', + 'user:@example.com', + 'user:password@example.com', + ], + invalid: [ + 'user:user:password@example.com', + '@example.com', + ':@example.com', + ':example.com', + ], + }); + }); + + it('should allow user to skip URL length validation', () => { + test({ + validator: 'isURL', + args: [{ validate_length: false }], + valid: [ + 'http://foobar.com/f', + `http://foobar.com/${new Array(2083).join('f')}`, + ], + invalid: [], + }); + }); + + it('should allow user to configure the maximum URL length', () => { + test({ + validator: 'isURL', + args: [{ max_allowed_length: 20 }], + valid: [ + 'http://foobar.com/12', // 20 characters + 'http://foobar.com/', + ], + invalid: [ + 'http://foobar.com/123', // 21 characters + 'http://foobar.com/1234567890', + ], + }); + }); + + it('should validate URLs with port present', () => { + test({ + validator: 'isURL', + args: [{ require_port: true }], + valid: [ + 'http://user:pass@www.foobar.com:1', + 'http://user:@www.foobar.com:65535', + 'http://127.0.0.1:23', + 'http://10.0.0.0:256', + 'http://189.123.14.13:256', + 'http://duckduckgo.com:65535?q=%2F', + ], + invalid: [ + 'http://user:pass@www.foobar.com/', + 'http://user:@www.foobar.com/', + 'http://127.0.0.1/', + 'http://10.0.0.0/', + 'http://189.123.14.13/', + 'http://duckduckgo.com/?q=%2F', + ], + }); + }); +});