From 87a5cde7340061bc3fd7bad8ba7b40b0d52ae971 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:50:00 +0000 Subject: [PATCH 01/10] fix(isURL): fix CVE-2025-56200 --- src/lib/isURL.js | 7 ++++++- test/validators.test.js | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/isURL.js b/src/lib/isURL.js index 0fec384ba..7a12c93cd 100644 --- a/src/lib/isURL.js +++ b/src/lib/isURL.js @@ -34,7 +34,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, @@ -76,6 +75,7 @@ export default function isURL(url, options) { } let protocol, auth, host, hostname, port, port_str, split, ipv6; + let has_protocol = false; split = url.split('#'); url = split.shift(); @@ -85,6 +85,7 @@ export default function isURL(url, options) { split = url.split('://'); if (split.length > 1) { + has_protocol = true; protocol = split.shift().toLowerCase(); if (options.require_valid_protocol && options.protocols.indexOf(protocol) === -1) { return false; @@ -95,6 +96,7 @@ export default function isURL(url, options) { if (!options.allow_protocol_relative_urls) { return false; } + has_protocol = true; split[0] = url.slice(2); } url = split.join('://'); @@ -119,6 +121,9 @@ export default function isURL(url, options) { return false; } auth = split.shift(); + if (!has_protocol && auth.indexOf(':') !== -1) { + return false; + } if (auth.indexOf(':') >= 0 && auth.split(':').length > 2) { return false; } diff --git a/test/validators.test.js b/test/validators.test.js index 12c5fc2ab..fa77528a9 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -466,6 +466,21 @@ describe('Validators', () => { '////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 */ ], }); }); From 2e08bb27f071a046af68183565c5d5e76beaf3e8 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:53:23 +0000 Subject: [PATCH 02/10] ci: remove Node 6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }} From 6aed799f8913943d08fedbdfe419d66a22dcf526 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:30:39 +0000 Subject: [PATCH 03/10] test(isURL): split isURL tests to separate file --- test/validators.test.js | 502 --------------------------------- test/validators/isURL.test.js | 505 ++++++++++++++++++++++++++++++++++ 2 files changed, 505 insertions(+), 502 deletions(-) create mode 100644 test/validators/isURL.test.js diff --git a/test/validators.test.js b/test/validators.test.js index fa77528a9..68d0d6186 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -377,508 +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//', - // 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', - ], - }); - }); - 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', + ], + }); + }); +}); From 45219946938fc9773d4f9a29e59fe337eda4604c Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:30:52 +0000 Subject: [PATCH 04/10] feat(isURL): rewrite isURL with native URL constructor --- src/lib/isURL.js | 296 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 227 insertions(+), 69 deletions(-) diff --git a/src/lib/isURL.js b/src/lib/isURL.js index 7a12c93cd..38eb11e33 100644 --- a/src/lib/isURL.js +++ b/src/lib/isURL.js @@ -50,8 +50,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)) { @@ -60,7 +58,24 @@ export default function isURL(url, options) { if (url.indexOf('mailto:') === 0) { return false; } - options = merge(options, default_url_options); + + // 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().startsWith(protocol))) { + return false; + } options = merge(options, default_url_options); if (options.validate_length && url.length > options.max_allowed_length) { return false; @@ -70,110 +85,253 @@ 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 has_protocol = false; + let originalUrl = url; + let hasProtocol = false; + let isProtocolRelative = false; - split = url.split('#'); - url = split.shift(); + // Check for multiple slashes like ////foobar.com or http:////foobar.com + // But allow file:/// which is a valid file URL pattern + if ( + url.startsWith('///') || + (originalUrl.match(/:\/\/\/\/+/) && !originalUrl.startsWith('file:///')) + ) { + return false; + } - split = url.split('?'); - url = split.shift(); + // Check for protocol-relative URLs (must start with exactly //) + if (url.startsWith('//') && !url.startsWith('///')) { + 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 (!originalUrl.includes('://')) { + // 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; + }); + + const knownDangerousProtocols = ['javascript', 'data', 'vbscript']; - split = url.split('://'); - if (split.length > 1) { - has_protocol = true; - protocol = split.shift().toLowerCase(); - if (options.require_valid_protocol && options.protocols.indexOf(protocol) === -1) { + if (!knownDangerousProtocols.includes(possibleProtocol) && + !knownDangerousProtocols.includes(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.startsWith('/') && !url.startsWith('//')) { return false; } - } else if (options.require_protocol) { - return false; - } else if (url.slice(0, 2) === '//') { - if (!options.allow_protocol_relative_urls) { + + // 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 { + // Use native URL constructor for parsing + try { + parsedUrl = new URL(url); + } catch (e) { return false; } - has_protocol = true; - 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 && + !options.protocols.includes(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.endsWith('://') && + (!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 (!has_protocol && auth.indexOf(':') !== -1) { - return false; - } - 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 === '' && + originalUrl.includes('@') && + !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 && parsedUrl.username.includes('.')) { + // 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 && originalUrl.includes('@/') && 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.startsWith('[') && hostname.endsWith(']')) { + 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; From cf66832fd38d65b22674a3095436bcc6b7366c90 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:47:40 +0000 Subject: [PATCH 05/10] ci: update rollup and split build/test ci jobs rollup didn't work anymore locally so I've updated it --- .github/workflows/ci.yml | 28 +++++++++++++++++++++++++++- build-browser.js | 6 +++--- package.json | 7 ++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0024eff3..bf67b2318 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,34 @@ on: pull_request: branches: [master] jobs: + build: + runs-on: ubuntu-latest + name: Build on Node.js 22 + steps: + - name: Setup Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install dependencies + run: npm install + - name: Build + run: npm run build + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + index.js + lib/ + es/ + validator.js + validator.min.js + retention-days: 1 test: runs-on: ubuntu-latest + needs: build strategy: matrix: node-version: [22, 20, 18, 16, 14, 12, 10, 8] @@ -21,7 +47,7 @@ jobs: - name: Install dependencies run: npm install --legacy-peer-deps - name: Run tests - run: npm test + run: npm test:ci - if: matrix.node-version == 22 name: Send coverage info to Codecov uses: codecov/codecov-action@v5 diff --git a/build-browser.js b/build-browser.js index c863bd399..ba901da75 100644 --- a/build-browser.js +++ b/build-browser.js @@ -6,7 +6,7 @@ import babelPresetEnv from "@babel/preset-env"; import pkg from "./package.json"; rollup({ - entry: "src/index.js", + input: "src/index.js", plugins: [ babel({ presets: [[babelPresetEnv, { modules: false }]], @@ -16,9 +16,9 @@ rollup({ }) .then((bundle) => bundle.write({ - dest: "validator.js", + file: "validator.js", format: "umd", - moduleName: pkg.name, + name: pkg.name, banner: `/*!\n${String(fs.readFileSync("./LICENSE")) .trim() .split("\n") diff --git a/package.json b/package.json index 7e84ef71d..e790d87d2 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "npm-run-all": "^4.1.5", "nyc": "^14.1.0", "rimraf": "^3.0.0", - "rollup": "^0.47.0", + "rollup": "^2.0.0", "rollup-plugin-babel": "^4.0.1", "timezone-mock": "^1.3.6", "uglify-js": "^3.0.19" @@ -66,8 +66,9 @@ "build:es": "babel src -d es --env-name=es", "build:node": "babel src -d .", "build": "run-p build:*", - "pretest": "npm run build && npm run lint", - "test": "nyc --reporter=cobertura --reporter=text-summary mocha --require @babel/register --reporter dot --recursive" + "pretest": "npm run build && npm run lint:fix", + "test": "npm run test", + "test:ci": "nyc --reporter=cobertura --reporter=text-summary mocha --require @babel/register --reporter dot --recursive" }, "engines": { "node": ">= 0.10" From 8347fdc03c0aa9aa68880bc7ff11e262330520a1 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:50:09 +0000 Subject: [PATCH 06/10] chore: fix typo --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf67b2318..77b3821dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - name: Install dependencies run: npm install --legacy-peer-deps - name: Run tests - run: npm test:ci + run: npm run test:ci - if: matrix.node-version == 22 name: Send coverage info to Codecov uses: codecov/codecov-action@v5 From f65e2e45492a9616d7f167aa351aa19faca2ec79 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:52:42 +0000 Subject: [PATCH 07/10] ci: undo CI changes --- .github/workflows/ci.yml | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77b3821dd..e0024eff3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,34 +5,8 @@ on: pull_request: branches: [master] jobs: - build: - runs-on: ubuntu-latest - name: Build on Node.js 22 - steps: - - name: Setup Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Checkout repository - uses: actions/checkout@v4 - - name: Install dependencies - run: npm install - - name: Build - run: npm run build - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: build-artifacts - path: | - index.js - lib/ - es/ - validator.js - validator.min.js - retention-days: 1 test: runs-on: ubuntu-latest - needs: build strategy: matrix: node-version: [22, 20, 18, 16, 14, 12, 10, 8] @@ -47,7 +21,7 @@ jobs: - name: Install dependencies run: npm install --legacy-peer-deps - name: Run tests - run: npm run test:ci + run: npm test - if: matrix.node-version == 22 name: Send coverage info to Codecov uses: codecov/codecov-action@v5 From 57f5a0b780baf2b34f2aa1589da75c2e3a381222 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:54:05 +0000 Subject: [PATCH 08/10] chore: undo build changes --- build-browser.js | 6 +++--- package.json | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/build-browser.js b/build-browser.js index ba901da75..c863bd399 100644 --- a/build-browser.js +++ b/build-browser.js @@ -6,7 +6,7 @@ import babelPresetEnv from "@babel/preset-env"; import pkg from "./package.json"; rollup({ - input: "src/index.js", + entry: "src/index.js", plugins: [ babel({ presets: [[babelPresetEnv, { modules: false }]], @@ -16,9 +16,9 @@ rollup({ }) .then((bundle) => bundle.write({ - file: "validator.js", + dest: "validator.js", format: "umd", - name: pkg.name, + moduleName: pkg.name, banner: `/*!\n${String(fs.readFileSync("./LICENSE")) .trim() .split("\n") diff --git a/package.json b/package.json index e790d87d2..7e84ef71d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "npm-run-all": "^4.1.5", "nyc": "^14.1.0", "rimraf": "^3.0.0", - "rollup": "^2.0.0", + "rollup": "^0.47.0", "rollup-plugin-babel": "^4.0.1", "timezone-mock": "^1.3.6", "uglify-js": "^3.0.19" @@ -66,9 +66,8 @@ "build:es": "babel src -d es --env-name=es", "build:node": "babel src -d .", "build": "run-p build:*", - "pretest": "npm run build && npm run lint:fix", - "test": "npm run test", - "test:ci": "nyc --reporter=cobertura --reporter=text-summary mocha --require @babel/register --reporter dot --recursive" + "pretest": "npm run build && npm run lint", + "test": "nyc --reporter=cobertura --reporter=text-summary mocha --require @babel/register --reporter dot --recursive" }, "engines": { "node": ">= 0.10" From 69c2aadac556cd6bd96c7fa3bc7a616d20fee106 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:08:23 +0000 Subject: [PATCH 09/10] fix: add back Node 8 compatibility --- src/lib/isURL.js | 64 +++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/src/lib/isURL.js b/src/lib/isURL.js index 38eb11e33..7191a30ce 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'; @@ -63,8 +64,8 @@ export default function isURL(url, options) { // 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); + if (code >= 0xff01 && code <= 0xff5e) { + return String.fromCharCode(code - 0xfee0); } return char; }); @@ -72,10 +73,14 @@ export default function isURL(url, options) { /* eslint-disable no-script-url */ const dangerousProtocolPrefixes = ['javascript:', 'data:', 'vbscript:']; /* eslint-enable no-script-url */ - if (dangerousProtocolPrefixes.some(protocol => - normalizedUrl.toLowerCase().startsWith(protocol))) { + if ( + dangerousProtocolPrefixes.some( + (protocol) => normalizedUrl.toLowerCase().indexOf(protocol) === 0 + ) + ) { return false; - } options = merge(options, default_url_options); + } + options = merge(options, default_url_options); if (options.validate_length && url.length > options.max_allowed_length) { return false; @@ -99,14 +104,14 @@ export default function isURL(url, options) { // Check for multiple slashes like ////foobar.com or http:////foobar.com // But allow file:/// which is a valid file URL pattern if ( - url.startsWith('///') || - (originalUrl.match(/:\/\/\/\/+/) && !originalUrl.startsWith('file:///')) + url.indexOf('///') === 0 || + (originalUrl.match(/:\/\/\/\/+/) && originalUrl.indexOf('file:///') !== 0) ) { return false; } // Check for protocol-relative URLs (must start with exactly //) - if (url.startsWith('//') && !url.startsWith('///')) { + if (url.indexOf('//') === 0 && url.indexOf('///') !== 0) { if (!options.allow_protocol_relative_urls) { return false; } @@ -115,7 +120,7 @@ export default function isURL(url, options) { 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 (!originalUrl.includes('://')) { + 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(/^([^:/@]+):([^@]*@[^/]+)/); @@ -123,19 +128,24 @@ export default function isURL(url, options) { 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); + 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; } - return char; - }); + ); const knownDangerousProtocols = ['javascript', 'data', 'vbscript']; - if (!knownDangerousProtocols.includes(possibleProtocol) && - !knownDangerousProtocols.includes(normalizedProtocol)) { + 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}`; @@ -152,7 +162,7 @@ export default function isURL(url, options) { } } else { // Single slash should not be treated as protocol-relative - if (url.startsWith('/') && !url.startsWith('//')) { + if (url.indexOf('/') === 0 && url.indexOf('//') !== 0) { return false; } @@ -195,7 +205,7 @@ export default function isURL(url, options) { if ( hasProtocol && options.require_valid_protocol && - !options.protocols.includes(protocol) + !includesArray(options.protocols, protocol) ) { return false; } @@ -211,7 +221,7 @@ export default function isURL(url, options) { if ( !parsedUrl.hostname && hasProtocol && - originalUrl.endsWith('://') && + originalUrl.indexOf('://') === originalUrl.length - 3 && (!parsedUrl.pathname || parsedUrl.pathname === '/') ) { return false; @@ -268,7 +278,7 @@ export default function isURL(url, options) { if ( parsedUrl.username === '' && parsedUrl.password === '' && - originalUrl.includes('@') && + includes(originalUrl, '@') && !originalUrl.match(/^[^:]+:@/) ) { return false; @@ -276,7 +286,7 @@ export default function isURL(url, options) { // 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 && parsedUrl.username.includes('.')) { + 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)) { @@ -287,14 +297,18 @@ export default function isURL(url, options) { let hostname = parsedUrl.hostname; // Special handling for URLs with empty hostnames but paths (like postgres://user:pw@/test) - if (!hostname && originalUrl.includes('@/') && hasProtocol) { + if (!hostname && includes(originalUrl, '@/') && hasProtocol) { // This is likely a database URL with empty hostname but a path return !options.require_host; } // Handle IPv6 addresses let isIPv6 = false; - if (hostname && hostname.startsWith('[') && hostname.endsWith(']')) { + if ( + hostname && + hostname.indexOf('[') === 0 && + hostname.indexOf(']') === hostname.length - 1 + ) { const ipv6Address = hostname.slice(1, -1); if (!isIP(ipv6Address, 6)) { return false; From 6e925269b32afabd5e95b4e56e4b9a7a042f412d Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:09:29 +0000 Subject: [PATCH 10/10] chore: fix lint --- src/lib/isURL.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/isURL.js b/src/lib/isURL.js index 7191a30ce..b25592fcb 100644 --- a/src/lib/isURL.js +++ b/src/lib/isURL.js @@ -74,9 +74,7 @@ export default function isURL(url, options) { const dangerousProtocolPrefixes = ['javascript:', 'data:', 'vbscript:']; /* eslint-enable no-script-url */ if ( - dangerousProtocolPrefixes.some( - (protocol) => normalizedUrl.toLowerCase().indexOf(protocol) === 0 - ) + dangerousProtocolPrefixes.some(protocol => normalizedUrl.toLowerCase().indexOf(protocol) === 0) ) { return false; }