From 2a6d357a18a68e6d812824379fd3388a1ae50d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ulises=20Gasc=C3=B3n?= Date: Mon, 29 Jun 2026 14:50:34 +0200 Subject: [PATCH 1/6] Merge commit from fork * test: cover IDN host canonicalisation Ref: https://github.com/fastify/fast-uri/security/advisories/GHSA-4c8g-83qw-93j6 * fix: canonicalise IDN hosts via WHATWG URL Ref: https://github.com/fastify/fast-uri/security/advisories/GHSA-4c8g-83qw-93j6 --- index.js | 2 +- test/security.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index eb50b65..b214fcb 100644 --- a/index.js +++ b/index.js @@ -305,7 +305,7 @@ function parseWithStatus (uri, opts) { if (parsed.host && (options.domainHost || (schemeHandler && schemeHandler.domainHost)) && isIP === false && nonSimpleDomain(parsed.host)) { // convert Unicode IDN -> ASCII IDN try { - parsed.host = URL.domainToASCII(parsed.host.toLowerCase()) + parsed.host = new URL('http://' + parsed.host).hostname } catch (e) { parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e } diff --git a/test/security.test.js b/test/security.test.js index e91b79c..0f4a24d 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -131,3 +131,31 @@ test('normalize does not double-decode %2540 into a live @', (t) => { t.plan(1) t.notEqual(parsed.host, 'trusted.com@evil.com', 'http://trusted.com%2540evil.com/') }) + +test('parse canonicalises IDN / Unicode hosts to their ASCII form', (t) => { + const cases = [ + { + input: 'http://127。0。0。1/', + expectedHost: '127.0.0.1', + description: 'full-width ideographic stops as octet separators' + }, + { + input: 'http://example.com/', + expectedHost: 'example.com', + description: 'fullwidth e as first letter' + }, + { + input: 'http://納豆.example.org/', + expectedHost: 'xn--99zt52a.example.org', + description: 'CJK label requiring punycode' + } + ] + + t.plan(cases.length * 2) + + cases.forEach(({ input, expectedHost, description }) => { + const parsed = fastURI.parse(input) + t.notOk(parsed.error, `parse should not set error: ${description}`) + t.equal(parsed.host, expectedHost, `host canonicalised to ASCII: ${description}`) + }) +}) From 0549fe35b0d482233f3be2816439f3ec803603fa Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 29 Jun 2026 14:53:12 +0200 Subject: [PATCH 2/6] Bumped v3.1.3 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 61e2a04..123f8cc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fast-uri", "description": "Dependency-free RFC 3986 URI toolbox", - "version": "3.1.2", + "version": "3.1.3", "main": "index.js", "type": "commonjs", "types": "types/index.d.ts", From 2d50fbabc80e4d0884fe0f6a98fe118ce6faa353 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 18 Jul 2026 19:05:12 +0200 Subject: [PATCH 3/6] fix: reject literal backslash in URI authority --- index.js | 17 ++++++++++++ test/security.test.js | 62 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/index.js b/index.js index b214fcb..f86398c 100644 --- a/index.js +++ b/index.js @@ -202,6 +202,10 @@ function serialize (cmpts, opts) { const URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u +// Captures the authority component (between "//" and the next "/", "?" or "#"), +// with or without a scheme prefix, for the literal-backslash rejection below. +const AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/ + /** * @param {import('./types/index').URIComponent} parsed * @param {RegExpMatchArray} matches @@ -248,6 +252,19 @@ function parseWithStatus (uri, opts) { } } + // A literal backslash (U+005C) is not a valid RFC 3986 URI character and is + // not an authority delimiter. Reject it in the authority rather than + // rewriting it: normalizing "\" -> "/" (WHATWG error recovery) could silently + // change the resource identified by an otherwise-invalid input, and lets "\" + // act as a host delimiter here while Node's native URL parses a different + // host (SSRF / redirect / origin-allowlist bypass). Percent-encoded %5C is + // untouched and remains valid encoded data. + const authorityMatch = uri.match(AUTHORITY_PREFIX) + if (authorityMatch !== null && authorityMatch[1].indexOf('\\') !== -1) { + parsed.error = 'URI authority must not contain a literal backslash.' + malformedAuthorityOrPort = true + } + const matches = uri.match(URI_PARSE) if (matches) { diff --git a/test/security.test.js b/test/security.test.js index 0f4a24d..96ed505 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -159,3 +159,65 @@ test('parse canonicalises IDN / Unicode hosts to their ASCII form', (t) => { t.equal(parsed.host, expectedHost, `host canonicalised to ASCII: ${description}`) }) }) + +test('parse rejects a literal backslash in the authority as malformed (RFC 3986)', (t) => { + // Regression for the host-confusion bypass: a literal "\" is invalid RFC 3986 + // syntax and must be flagged malformed, not silently rewritten. Otherwise "\" + // acts as a host delimiter here while Node's native URL parses a different + // host, defeating a host-based SSRF/redirect/origin allowlist. + const cases = [ + 'http://evil.com\\@allowed.com', + 'https://169.254.169.254\\@trusted.example.com', + 'http://127.0.0.1\\@public.example.com', + 'https://attacker.com\\@api.internal', + 'http://a\\@b', + 'ws://evil.com\\@allowed.com/chat', + 'wss://evil.com\\@allowed.com/chat', + 'http://evil.com\\%40allowed.com', + '//evil.com\\@allowed.com' + ] + + t.plan(cases.length) + + cases.forEach((input) => { + t.equal( + fastURI.parse(input).error, + 'URI authority must not contain a literal backslash.', + input + ) + }) +}) + +test('normalize does not canonicalize a literal-backslash URI into a different valid URL', (t) => { + const cases = [ + 'http://evil.com\\@allowed.com', + 'https://attacker.com\\@api.internal' + ] + + t.plan(cases.length) + + cases.forEach((input) => { + t.equal(fastURI.normalize(input), input, input) + }) +}) + +test('parse leaves percent-encoded %5C untouched as encoded data (not rejected)', (t) => { + // Only the literal "\" byte is rejected; %5C stays valid encoded data and + // does not diverge from the native URL parser, so it must not be flagged. + const input = 'http://evil.com%5C@allowed.com' + const parsed = fastURI.parse(input) + + t.plan(2) + t.notOk(parsed.error, '%5C is valid encoded data, not malformed') + t.equal(parsed.host, new URL(input).hostname, '%5C host matches native URL (no divergence)') +}) + +test('parse does not reject a literal backslash in the query or fragment', (t) => { + // The rejection is scoped to the authority/path (the host-confusion surface); + // a backslash after "?"/"#" is normalized as encoded data as before. + const parsed = fastURI.parse('http://host.example.com/?x=\\y#z\\w') + + t.plan(2) + t.notOk(parsed.error, 'backslash in query/fragment does not mark the URI malformed') + t.equal(parsed.host, 'host.example.com', 'host parsed normally') +}) From 6aeece669e4166b2446a89f17c07a3b15dfb7ed4 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 19 Jul 2026 09:42:22 +0200 Subject: [PATCH 4/6] Bumped v3.1.4 Signed-off-by: Matteo Collina --- .gitignore | 8 +++++++- package.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2b6aed4..a20c730 100644 --- a/.gitignore +++ b/.gitignore @@ -148,5 +148,11 @@ yarn.lock .vscode .idea -#tap files +# tap files .tap/ + +# AI files +.pi +.claude +CLAUDE.md +AGENTS.md diff --git a/package.json b/package.json index 123f8cc..dbfdf91 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fast-uri", "description": "Dependency-free RFC 3986 URI toolbox", - "version": "3.1.3", + "version": "3.1.4", "main": "index.js", "type": "commonjs", "types": "types/index.d.ts", From 2cad02d6ed428a720499bb7a3c3d6c3d41f10f5a Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 31 Jul 2026 10:01:03 +0100 Subject: [PATCH 5/6] Merge commit from fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: reject malformed authority introducer (\, /\, \/) to prevent host-confusion Adds MALFORMED_AUTHORITY_INTRODUCER regex and check in parseWithStatus() to detect "\\", "/\", and "\/" used as authority introducers in place of "//" after the scheme colon. Node's URL treats "\" as interchangeable with "/" on special schemes, so these inputs would parse as a different host — an SSRF/redirect bypass gap distinct from the previously-patched literal-backslash-in-authority fix (GHSA-v2hh-gcrm-f6hx). Also makes resolve() throw on malformed authority or port, since it returns a plain string with no error-signal mechanism. Adds 23 regression tests covering rejection, normalization, equal(), resolve(), and no false positives. * fix: reject whitespace-split authority introducer (TAB/LF/CR) host-confusion bypass --------- Co-authored-by: Ulises Gascon --- index.js | 38 +++++++++++- test/security.test.js | 136 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index f86398c..fd8ad70 100644 --- a/index.js +++ b/index.js @@ -26,7 +26,12 @@ function normalize (uri, options) { */ function resolve (baseURI, relativeURI, options) { const schemelessOptions = options ? Object.assign({ scheme: 'null' }, options) : { scheme: 'null' } - const resolved = resolveComponent(parse(baseURI, schemelessOptions), parse(relativeURI, schemelessOptions), schemelessOptions, true) + const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions) + const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions) + if (baseMalformed || relativeMalformed) { + throw new Error(baseParsed.error || relativeParsed.error || 'URI is malformed.') + } + const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true) schemelessOptions.skipEscape = true return serialize(resolved, schemelessOptions) } @@ -206,6 +211,15 @@ const URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/: // with or without a scheme prefix, for the literal-backslash rejection below. const AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/ +// Captures the leading authority-introducer region after an optional scheme: a +// run of forward slashes, backslashes, and the characters the WHATWG URL parser +// removes before parsing (TAB U+0009, LF U+000A, CR U+000D). A valid introducer +// is exactly "//". Node treats "\" as "/" on special schemes and strips those +// characters first, so forms like "\\", "/\", "\/", "//", or a leading +// "//" reach an authority in Node while fast-uri's URI_PARSE folds them into +// the path group (host confusion / SSRF / redirect bypass). +const AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/ + /** * @param {import('./types/index').URIComponent} parsed * @param {RegExpMatchArray} matches @@ -265,6 +279,28 @@ function parseWithStatus (uri, opts) { malformedAuthorityOrPort = true } + // Reject a malformed or whitespace-smuggled authority introducer. fast-uri + // only recognizes a literal "//"; anything else in the leading separator run + // (a backslash, or a "//" that appears only after removing the TAB/LF/CR that + // Node strips) means the authority fast-uri parses differs from the one Node's + // URL resolves. Reject rather than rewrite, mirroring the literal-backslash + // guard above. Percent-encoded forms (%5C, %09) are untouched, valid data. + const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION) + if (introducerMatch !== null) { + const region = introducerMatch[1] + const normalizedRegion = region.replace(/[\t\n\r]/g, '') + // Two or more leading separators introduce an authority. + if (normalizedRegion.length >= 2) { + if (normalizedRegion.slice(0, 2) !== '//') { + parsed.error = parsed.error || 'URI authority must not contain a literal backslash.' + malformedAuthorityOrPort = true + } else if (region.length !== normalizedRegion.length) { + parsed.error = parsed.error || 'URI authority introducer must not contain whitespace.' + malformedAuthorityOrPort = true + } + } + } + const matches = uri.match(URI_PARSE) if (matches) { diff --git a/test/security.test.js b/test/security.test.js index 96ed505..4c11cf2 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -221,3 +221,139 @@ test('parse does not reject a literal backslash in the query or fragment', (t) = t.notOk(parsed.error, 'backslash in query/fragment does not mark the URI malformed') t.equal(parsed.host, 'host.example.com', 'host parsed normally') }) + +test('parse rejects a malformed authority introducer (\\\\, /\\, \\/) in place of //', (t) => { + // Regression: "\\", "/\\", "\\/" after the scheme colon are not valid authority + // introducers. Node's URL treats "\\" as interchangeable with "/" on special + // schemes, so "http:\\\\evil.com/path" would be parsed as host "evil.com" by + // Node, but fast-uri must reject it as malformed to prevent SSRF/redirect bypass. + const cases = [ + 'http:\\\\evil.com/path', + 'http:/\\evil.com/path', + 'http:\\/evil.com/path', + 'ws:\\\\evil.com/chat', + 'wss:\\\\evil.com/chat', + 'ftp:\\\\evil.com/', + '\\\\evil.com/path' + ] + + t.plan(cases.length) + + cases.forEach((input) => { + t.equal( + fastURI.parse(input).error, + 'URI authority must not contain a literal backslash.', + input + ) + }) +}) + +test('normalize does not canonicalize a malformed-authority-introducer URI', (t) => { + const cases = [ + 'http:\\\\evil.com/path', + 'http:/\\evil.com/path' + ] + + t.plan(cases.length) + + cases.forEach((input) => { + t.equal(fastURI.normalize(input), input, input) + }) +}) + +test('equal returns false for malformed-authority-introducer URIs', (t) => { + const pairs = [ + ['http:\\\\evil.com/path', 'http://evil.com/path'], + ['http:/\\evil.com/path', 'http://evil.com/path'] + ] + + t.plan(pairs.length) + + pairs.forEach(([left, right]) => { + t.equal(fastURI.equal(left, right), false, `${left} != ${right}`) + }) +}) + +test('resolve throws on malformed authority introducer', (t) => { + // resolve() returns a plain string with no error field, so the only safe + // behavior is to throw when either component has a malformed authority. + const pairs = [ + ['https://allowed.com/', '\\\\evil.com/path'], + ['\\\\evil.com/path', 'https://allowed.com/'], + ['https://allowed.com/', 'http:/\\evil.com/path'], + ['https://allowed.com/', 'http:\\/evil.com/path'] + ] + + t.plan(pairs.length) + + pairs.forEach(([base, rel]) => { + t.throws( + () => fastURI.resolve(base, rel), + /URI authority must not contain a literal backslash/, + `${base} + ${rel}` + ) + }) +}) + +test('parse rejects a whitespace-split authority introducer (TAB, LF, CR)', (t) => { + // The WHATWG URL parser removes TAB (U+0009), LF (U+000A) and CR (U+000D) from + // the input before parsing, so a stripped character wedged into the introducer + // ("/\\", "//", or a leading "//") reaches an authority in Node + // while fast-uri would otherwise fold it into the path. These must be rejected + // like the adjacent "\\", "/\\", "\\/" forms. + const cases = [ + { input: '/\t\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' }, + { input: '/\t/evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' }, + { input: '/\n\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' }, + { input: '/\r\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' }, + { input: '\t//evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' }, + { input: '\t/\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' }, + { input: 'https:/\t/evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' } + ] + + t.plan(cases.length) + + cases.forEach(({ input, expectedError }) => { + t.equal(fastURI.parse(input).error, expectedError, JSON.stringify(input)) + }) +}) + +test('resolve throws on a whitespace-split authority introducer', (t) => { + const pairs = [ + ['https://allowed.com/', '/\t\\evil.com/path'], + ['https://allowed.com/', '/\t/evil.com/path'], + ['https://allowed.com/', '/\n\\evil.com/path'], + ['/\t/evil.com/path', 'https://allowed.com/'] + ] + + t.plan(pairs.length) + + pairs.forEach(([base, rel]) => { + t.throws( + () => fastURI.resolve(base, rel), + /URI authority (must not contain a literal backslash|introducer must not contain whitespace)/, + `${JSON.stringify(base)} + ${JSON.stringify(rel)}` + ) + }) +}) + +test('parse does not reject valid authority introducer patterns', (t) => { + // No false positives: "//" introducer and scheme-less "//" must be valid. + const cases = [ + 'http://good.com/', + 'https://good.com/', + 'ws://good.com/chat', + 'wss://good.com/chat', + 'ftp://good.com/', + '//good.com/path', + '/absolute/path', + 'relative/path' + ] + + t.plan(cases.length) + + cases.forEach((input) => { + const parsed = fastURI.parse(input) + t.notOk(parsed.error, input) + }) +}) From 5e179cbb4636d5f773ed21126e5bd3068e87e94e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 31 Jul 2026 10:14:20 +0100 Subject: [PATCH 6/6] Bumped v3.1.5 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dbfdf91..69bbc7a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fast-uri", "description": "Dependency-free RFC 3986 URI toolbox", - "version": "3.1.4", + "version": "3.1.5", "main": "index.js", "type": "commonjs", "types": "types/index.d.ts",