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;
}