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',
+ ],
+ });
+ });
+});