From 9079560a96b47ddc90ffacc3fe9574240440cecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20FIDRY?= Date: Wed, 15 Oct 2025 13:35:07 +0200 Subject: [PATCH 01/10] test: Add a test to capture the GHSA-9965-vmph-33xx vulnerability --- test/validators.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/validators.test.js b/test/validators.test.js index 12c5fc2ab..3a527c48b 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -782,6 +782,25 @@ describe('Validators', () => { }); }); + it('GHSA-9965-vmph-33xx vulnerability - protocol delimiter parsing difference', () => { + const DOMAIN_WHITELIST = ['example.com']; + + test({ + validator: 'isURL', + args: [{ + protocols: ['https'], + host_whitelist: DOMAIN_WHITELIST, + require_host: false, + }], + valid: [ + // TODO: the expected result is **INVALID**. + // eslint-disable-next-line no-script-url + "javascript:alert(1);a=';@example.com/alert(1)", + ], + invalid: [], + }); + }); + it('should allow rejecting urls containing authentication information', () => { test({ validator: 'isURL', From e4d890de1f53c214db67fa7fd1c55675743d663e Mon Sep 17 00:00:00 2001 From: manuelMarkDenver Date: Tue, 14 Oct 2025 11:41:45 +0800 Subject: [PATCH 02/10] fix(isURL): prevent URL validation bypass by improving protocol detection --- src/lib/isURL.js | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/lib/isURL.js b/src/lib/isURL.js index 0fec384ba..5027e31cd 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, @@ -71,7 +70,10 @@ 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; } @@ -83,21 +85,33 @@ export default function isURL(url, options) { split = url.split('?'); url = split.shift(); - split = url.split('://'); - if (split.length > 1) { - protocol = split.shift().toLowerCase(); - if (options.require_valid_protocol && options.protocols.indexOf(protocol) === -1) { - return false; + // Replaced the 'split("://")' logic with a regex to match the protocol. + // This correctly identifies schemes like `javascript:` which don't use `//`. + const protocol_match = url.match(/^([a-z][a-z0-9+\-.]*):/i); + const hadExplicitProtocol = !!protocol_match; + + if (protocol_match) { + protocol = protocol_match[1].toLowerCase(); + if ( + options.require_valid_protocol && + options.protocols.indexOf(protocol) === -1 + ) { + return false; // The identified protocol is not in the allowed list. } + url = url.substring(protocol_match[0].length); // Remove the protocol from the URL string. } else if (options.require_protocol) { - return false; - } else if (url.slice(0, 2) === '//') { - if (!options.allow_protocol_relative_urls) { + return false; // A protocol was required but not found. + } + + // Handle leading '//' only as protocol-relative when there was NO explicit protocol. + // If there was an explicit protocol, '//' is the normal separator + // and should be stripped unconditionally. + if (url.slice(0, 2) === '//') { + if (!hadExplicitProtocol && !options.allow_protocol_relative_urls) { return false; } - split[0] = url.slice(2); + url = url.slice(2); // Remove the '//' from the URL string. } - url = split.join('://'); if (url === '') { return false; From 370699624f01e3687c81b2388b4a0772717a7839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20FIDRY?= Date: Wed, 15 Oct 2025 14:05:56 +0200 Subject: [PATCH 03/10] fix(isURL): Correct the patch and apply feedback --- .github/workflows/ci.yml | 1 + src/lib/isURL.js | 89 +++++++++++++++++++++++++++++++++------- test/validators.test.js | 23 +++++++++-- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af090a9be..96dd156c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: strategy: matrix: node-version: [22, 20, 18, 16, 14, 12, 10, 8, 6] + fail-fast: false name: Run tests on Node.js ${{ matrix.node-version }} steps: - name: Setup Node.js ${{ matrix.node-version }} diff --git a/src/lib/isURL.js b/src/lib/isURL.js index 5027e31cd..8ae971ed6 100644 --- a/src/lib/isURL.js +++ b/src/lib/isURL.js @@ -34,6 +34,7 @@ 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, @@ -70,10 +71,7 @@ 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; } @@ -87,30 +85,91 @@ export default function isURL(url, options) { // Replaced the 'split("://")' logic with a regex to match the protocol. // This correctly identifies schemes like `javascript:` which don't use `//`. + // However, we need to be careful not to confuse authentication credentials (user:password@host) + // with protocols. A colon before an @ symbol might be part of auth, not a protocol separator. const protocol_match = url.match(/^([a-z][a-z0-9+\-.]*):/i); - const hadExplicitProtocol = !!protocol_match; + let had_explicit_protocol = false; + + const cleanUpProtocol = (potential_protocol) => { + had_explicit_protocol = true; + protocol = potential_protocol.toLowerCase(); + + if (options.require_valid_protocol && options.protocols.indexOf(protocol) === -1) { + // The identified protocol is not in the allowed list. + return false; + } + + // Remove the protocol from the URL string. + return url.substring(protocol_match[0].length); + }; if (protocol_match) { - protocol = protocol_match[1].toLowerCase(); - if ( - options.require_valid_protocol && - options.protocols.indexOf(protocol) === -1 - ) { - return false; // The identified protocol is not in the allowed list. + const potential_protocol = protocol_match[1]; + const after_colon = url.substring(protocol_match[0].length); + + // Check if what follows looks like authentication credentials (user:password@host) + // rather than a protocol. This happens when: + // 1. There's no `//` after the colon (protocols like `http://` have this) + // 2. There's an `@` symbol before any `/` + // 3. The part before `@` contains only valid auth characters (alphanumeric, -, _, ., %, :) + const starts_with_slashes = after_colon.slice(0, 2) === '//'; + + if (!starts_with_slashes) { + const first_slash_position = after_colon.indexOf('/'); + const before_slash = first_slash_position === -1 + ? after_colon + : after_colon.substring(0, first_slash_position); + const at_position = before_slash.indexOf('@'); + + if (at_position !== -1) { + const before_at = before_slash.substring(0, at_position); + const valid_auth_regex = /^[a-zA-Z0-9\-_.%:]*$/; + const is_valid_auth = valid_auth_regex.test(before_at); + + if (is_valid_auth) { + // This looks like authentication (e.g., user:password@host), not a protocol + if (options.require_protocol) { + return false; + } + + // Don't consume the colon; let the auth parsing handle it later + } else { + // This looks like a malicious protocol (e.g., javascript:alert();@host) + url = cleanUpProtocol(potential_protocol); + + if (url === false) { + return false; + } + } + } else { + // No @ symbol, this is definitely a protocol + url = cleanUpProtocol(potential_protocol); + + if (url === false) { + return false; + } + } + } else { + // Starts with '//', this is definitely a protocol like http:// + url = cleanUpProtocol(potential_protocol); + + if (url === false) { + return false; + } } - url = url.substring(protocol_match[0].length); // Remove the protocol from the URL string. } else if (options.require_protocol) { - return false; // A protocol was required but not found. + return false; } // Handle leading '//' only as protocol-relative when there was NO explicit protocol. // If there was an explicit protocol, '//' is the normal separator // and should be stripped unconditionally. if (url.slice(0, 2) === '//') { - if (!hadExplicitProtocol && !options.allow_protocol_relative_urls) { + if (!had_explicit_protocol && !options.allow_protocol_relative_urls) { return false; } - url = url.slice(2); // Remove the '//' from the URL string. + + url = url.slice(2); } if (url === '') { diff --git a/test/validators.test.js b/test/validators.test.js index 3a527c48b..ec8154e49 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -424,6 +424,12 @@ describe('Validators', () => { 'http://[2010:836B:4179::836B:4179]', 'http://example.com/example.json#/foo/bar', 'http://1337.com', + // TODO: those should not be marked as valid URLs; CVE-2025-56200 + /* eslint-disable no-script-url */ + 'javascript:%61%6c%65%72%74%28%31%29@example.com', + 'http://evil-site.com@example.com/', + 'javascript:alert(1)@example.com', + /* eslint-enable no-script-url */ ], invalid: [ 'http://localhost:3000/', @@ -466,6 +472,18 @@ 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:/* 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', + /* eslint-enable no-script-url */ ], }); }); @@ -792,12 +810,11 @@ describe('Validators', () => { host_whitelist: DOMAIN_WHITELIST, require_host: false, }], - valid: [ - // TODO: the expected result is **INVALID**. + valid: [], + invalid: [ // eslint-disable-next-line no-script-url "javascript:alert(1);a=';@example.com/alert(1)", ], - invalid: [], }); }); From d7a67ebf08086ba61b5291354e80d45be7581c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20FIDRY?= Date: Thu, 16 Oct 2025 00:03:44 +0200 Subject: [PATCH 04/10] docs: Add mention in the documentation. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a0ea7a65d..37fbd5e3d 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Validator | Description **isStrongPassword(str [, options])** | check if the string can be considered a strong password or not. Allows for custom requirements or scoring rules. If `returnScore` is true, then the function returns an integer score for the password rather than a boolean.
Default options:
`{ minLength: 8, minLowercase: 1, minUppercase: 1, minNumbers: 1, minSymbols: 1, returnScore: false, pointsPerUnique: 1, pointsPerRepeat: 0.5, pointsForContainingLower: 10, pointsForContainingUpper: 10, pointsForContainingNumber: 10, pointsForContainingSymbol: 10 }` **isTime(str [, options])** | check if the string is a valid time e.g. [`23:01:59`, new Date().toLocaleTimeString()].

`options` is an object which can contain the keys `hourFormat` or `mode`.

`hourFormat` is a key and defaults to `'hour24'`.

`mode` is a key and defaults to `'default'`.

`hourFormat` can contain the values `'hour12'` or `'hour24'`, `'hour24'` will validate hours in 24 format and `'hour12'` will validate hours in 12 format.

`mode` can contain the values `'default', 'withSeconds', withOptionalSeconds`, `'default'` will validate `HH:MM` format, `'withSeconds'` will validate the `HH:MM:SS` format, `'withOptionalSeconds'` will validate `'HH:MM'` and `'HH:MM:SS'` formats. **isTaxID(str, locale)** | check if the string is a valid Tax Identification Number. Default locale is `en-US`.

More info about exact TIN support can be found in `src/lib/isTaxID.js`.

Supported locales: `[ 'bg-BG', 'cs-CZ', 'de-AT', 'de-DE', 'dk-DK', 'el-CY', 'el-GR', 'en-CA', 'en-GB', 'en-IE', 'en-US', 'es-AR', 'es-ES', 'et-EE', 'fi-FI', 'fr-BE', 'fr-CA', 'fr-FR', 'fr-LU', 'hr-HR', 'hu-HU', 'it-IT', 'lb-LU', 'lt-LT', 'lv-LV', 'mt-MT', 'nl-BE', 'nl-NL', 'pl-PL', 'pt-BR', 'pt-PT', 'ro-RO', 'sk-SK', 'sl-SI', 'sv-SE', 'uk-UA']`. -**isURL(str [, options])** | check if the string is a URL.

`options` is an object which defaults to `{ protocols: ['http','https','ftp'], require_tld: true, require_protocol: false, require_host: true, require_port: false, require_valid_protocol: true, allow_underscores: false, host_whitelist: false, host_blacklist: false, allow_trailing_dot: false, allow_protocol_relative_urls: false, allow_fragments: true, allow_query_components: true, disallow_auth: false, validate_length: true }`.

`protocols` - valid protocols can be modified with this option.
`require_tld` - If set to false isURL will not check if the URL's host includes a top-level domain.
`require_protocol` - if set to true isURL will return false if protocol is not present in the URL.
`require_host` - if set to false isURL will not check if host is present in the URL.
`require_port` - if set to true isURL will check if port is present in the URL.
`require_valid_protocol` - isURL will check if the URL's protocol is present in the protocols option.
`allow_underscores` - if set to true, the validator will allow underscores in the URL.
`host_whitelist` - if set to an array of strings or regexp, and the domain matches none of the strings defined in it, the validation fails.
`host_blacklist` - if set to an array of strings or regexp, and the domain matches any of the strings defined in it, the validation fails.
`allow_trailing_dot` - if set to true, the validator will allow the domain to end with a `.` character.
`allow_protocol_relative_urls` - if set to true protocol relative URLs will be allowed.
`allow_fragments` - if set to false isURL will return false if fragments are present.
`allow_query_components` - if set to false isURL will return false if query components are present.
`disallow_auth` - if set to true, the validator will fail if the URL contains an authentication component, e.g. `http://username:password@example.com`.
`validate_length` - if set to false isURL will skip string length validation. `max_allowed_length` will be ignored if this is set as `false`.
`max_allowed_length` - if set, isURL will not allow URLs longer than the specified value (default is 2084 that IE maximum URL length).
+**isURL(str [, options])** | check if the string is a URL.

`options` is an object which defaults to `{ protocols: ['http','https','ftp'], require_tld: true, require_protocol: false, require_host: true, require_port: false, require_valid_protocol: true, allow_underscores: false, host_whitelist: false, host_blacklist: false, allow_trailing_dot: false, allow_protocol_relative_urls: false, allow_fragments: true, allow_query_components: true, disallow_auth: false, validate_length: true }`.

`protocols` - valid protocols can be modified with this option.
`require_tld` - If set to false isURL will not check if the URL's host includes a top-level domain.
`require_protocol` - **RECOMMENDED** if set to true isURL will return false if protocol is not present in the URL. Without this setting, some malicious URLs cannot be distinguishable from a valid URL with authentication information.
`require_host` - if set to false isURL will not check if host is present in the URL.
`require_port` - if set to true isURL will check if port is present in the URL.
`require_valid_protocol` - isURL will check if the URL's protocol is present in the protocols option.
`allow_underscores` - if set to true, the validator will allow underscores in the URL.
`host_whitelist` - if set to an array of strings or regexp, and the domain matches none of the strings defined in it, the validation fails.
`host_blacklist` - if set to an array of strings or regexp, and the domain matches any of the strings defined in it, the validation fails.
`allow_trailing_dot` - if set to true, the validator will allow the domain to end with a `.` character.
`allow_protocol_relative_urls` - if set to true protocol relative URLs will be allowed.
`allow_fragments` - if set to false isURL will return false if fragments are present.
`allow_query_components` - if set to false isURL will return false if query components are present.
`disallow_auth` - if set to true, the validator will fail if the URL contains an authentication component, e.g. `http://username:password@example.com`.
`validate_length` - if set to false isURL will skip string length validation. `max_allowed_length` will be ignored if this is set as `false`.
`max_allowed_length` - if set, isURL will not allow URLs longer than the specified value (default is 2084 that IE maximum URL length).
**isULID(str)** | check if the string is a [ULID](https://github.com/ulid/spec). **isUUID(str [, version])** | check if the string is an RFC9562 UUID.
`version` is one of `'1'`-`'8'`, `'nil'`, `'max'`, `'all'` or `'loose'`. The `'loose'` option checks if the string is a UUID-like string with hexadecimal values, ignoring RFC9565. **isVariableWidth(str)** | check if the string contains a mixture of full and half-width chars. From 6d90c60e88ca5d3d5816845554f3c1512a8c63b9 Mon Sep 17 00:00:00 2001 From: scottgigante-hubflow Date: Fri, 17 Oct 2025 11:07:59 -0700 Subject: [PATCH 05/10] test(isURL): Add more tests --- test/validators.test.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/validators.test.js b/test/validators.test.js index ec8154e49..435868891 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -496,9 +496,11 @@ describe('Validators', () => { }], valid: [ 'rtmp://foobar.com', + 'rtmp:foobar.com', ], invalid: [ 'http://foobar.com', + 'tel:+15551234567', ], }); }); @@ -722,6 +724,21 @@ describe('Validators', () => { }); }); + it('should validate authentication strings if a protocol is not required', () => { + test({ + validator: 'isURL', + args: [{ + require_protocol: false, + }], + valid: [ + 'user:pw@foobar.com/', + ], + invalid: [ + 'user:pw,@foobar.com/', + ], + }); + }); + it('should let users specify a host whitelist', () => { test({ validator: 'isURL', From a37cc4dd094b9085d5ae4a51f1c96864bd822a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20FIDRY?= Date: Fri, 17 Oct 2025 20:19:53 +0200 Subject: [PATCH 06/10] test(isURL): Add more tests --- test/validators.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/validators.test.js b/test/validators.test.js index 435868891..ff0e87803 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -739,6 +739,46 @@ describe('Validators', () => { }); }); + it('should reject authentication strings if a protocol is required', () => { + test({ + validator: 'isURL', + args: [{ + require_protocol: true, + }], + valid: [ + 'http://user:pw@foobar.com/', + 'https://user:password@example.com', + 'ftp://admin:pass@ftp.example.com/', + ], + invalid: [ + 'user:pw@foobar.com/', + 'user:password@example.com', + 'admin:pass@ftp.example.com/', + ], + }); + }); + + it('should reject invalid protocols when require_valid_protocol is enabled', () => { + test({ + validator: 'isURL', + args: [{ + require_valid_protocol: true, + protocols: ['http', 'https', 'ftp'], + }], + valid: [ + 'http://example.com', + 'https://example.com', + 'ftp://example.com', + ], + invalid: [ + // eslint-disable-next-line no-script-url + 'javascript:alert(1);@example.com', + 'data:text/html,@example.com', + 'file:///etc/passwd@example.com', + ], + }); + }); + it('should let users specify a host whitelist', () => { test({ validator: 'isURL', From 49d2408a614440eb690571879970e517d267550d Mon Sep 17 00:00:00 2001 From: Henri Holopainen Date: Mon, 20 Oct 2025 23:50:21 +0300 Subject: [PATCH 07/10] test(isURL): Add coverage for missing else path (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Co-authored-by: Théo FIDRY <5175937+theofidry@users.noreply.github.com> --- test/validators.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/validators.test.js b/test/validators.test.js index ff0e87803..a1c0d8aeb 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -553,6 +553,9 @@ describe('Validators', () => { 'rtmp://foobar.com', 'http://foobar.com', 'test://foobar.com', + // Dangerous! This allows to mark malicious URLs as a valid URL (CVE-2025-56200) + // eslint-disable-next-line no-script-url + 'javascript:alert(1);@example.com', ], invalid: [ 'mailto:test@example.com', From 1f52cc9995faadbe37e8502dcd1d9ead88e0ad6a Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:46:09 +0200 Subject: [PATCH 08/10] Update test/validators.test.js --- test/validators.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/validators.test.js b/test/validators.test.js index a1c0d8aeb..a3c5f5a5d 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -424,7 +424,7 @@ describe('Validators', () => { 'http://[2010:836B:4179::836B:4179]', 'http://example.com/example.json#/foo/bar', 'http://1337.com', - // TODO: those should not be marked as valid URLs; CVE-2025-56200 + // TODO: those probably should not be marked as valid URLs; CVE-2025-56200 /* eslint-disable no-script-url */ 'javascript:%61%6c%65%72%74%28%31%29@example.com', 'http://evil-site.com@example.com/', From 17aa26a7fcdeab3f9689b500624bb39ce2951418 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:46:15 +0200 Subject: [PATCH 09/10] Update .github/workflows/ci.yml --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96dd156c6..63db0fc66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [22, 20, 18, 16, 14, 12, 10, 8, 6] - fail-fast: false + 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 376c84f4d5c7bbc3fa9a6640af24799357b4db71 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:49:29 +0200 Subject: [PATCH 10/10] Update .github/workflows/ci.yml --- .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 63db0fc66..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] + 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 }}