| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| test('userinfo serialization cannot terminate the authority', (t) => { | ||
| const uri = fastURI.serialize({ | ||
| scheme: 'http', | ||
| userinfo: 'attacker.example/', | ||
| host: 'trusted.example', | ||
| path: '/' | ||
| }) | ||
| t.equal(uri, 'http://attacker.example%2F@trusted.example/', 'slash is encoded as userinfo data') | ||
| const reparsed = fastURI.parse(uri) | ||
| t.equal(reparsed.host, 'trusted.example', 'reparsing retains the supplied host') | ||
| t.equal(reparsed.userinfo, 'attacker.example%2F', 'encoded slash remains in userinfo') | ||
| t.equal( | ||
| fastURI.serialize({ userinfo: 'user@/?:#[]\\', host: 'example.test' }), | ||
| '//user%40%2F%3F:%23%5B%5D%5C@example.test', | ||
| 'all userinfo component delimiters are encoded as data' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('query and fragment serialization cannot inject component delimiters', (t) => { | ||
| const uri = fastURI.serialize({ | ||
| scheme: 'x', | ||
| path: '/resource', | ||
| query: 'key=value#still-query', | ||
| fragment: 'first#still-fragment' | ||
| }) | ||
| t.equal( | ||
| uri, | ||
| 'x:/resource?key=value%23still-query#first%23still-fragment', | ||
| 'raw hashes are encoded within their supplied components' | ||
| ) | ||
| const reparsed = fastURI.parse(uri) | ||
| t.equal(reparsed.query, 'key=value%23still-query', 'hash remains query data') | ||
| t.equal(reparsed.fragment, 'first%23still-fragment', 'hash remains fragment data') | ||
| t.end() | ||
| }) | ||
| test('component serializers preserve each RFC 3986 literal character set', (t) => { | ||
| const unreservedAndSubDelims = "AZaz09-._~!$&'()*+,;=" | ||
| const userinfo = unreservedAndSubDelims + ':' | ||
| const queryOrFragment = unreservedAndSubDelims + ':@/?' | ||
| t.equal( | ||
| fastURI.serialize({ userinfo, host: 'example.test' }), | ||
| '//' + userinfo + '@example.test', | ||
| 'userinfo literals are preserved' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ query: queryOrFragment }), | ||
| '?' + queryOrFragment, | ||
| 'query literals are preserved' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ fragment: queryOrFragment }), | ||
| '#' + queryOrFragment, | ||
| 'fragment literals are preserved' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('component serializers preserve escapes and encode Unicode as UTF-8', (t) => { | ||
| t.equal( | ||
| fastURI.serialize({ userinfo: 'café/%2f%GG', host: 'example.test' }), | ||
| '//caf%C3%A9%2F%2F%25GG@example.test', | ||
| 'userinfo preserves valid escapes, uppercases hex, and escapes malformed percent data' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ query: '日本/%2f#%GG' }), | ||
| '?%E6%97%A5%E6%9C%AC/%2F%23%25GG', | ||
| 'query uses UTF-8 and does not double encode valid escapes' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ fragment: '😀/%3f#%GG' }), | ||
| '#%F0%9F%98%80/%3F%23%25GG', | ||
| 'fragment uses UTF-8 and does not double encode valid escapes' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ query: 'Kſ' }), | ||
| '?%E2%84%AA%C5%BF', | ||
| 'Unicode characters that case-fold to ASCII are still UTF-8 encoded' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('parsed URI serialization remains stable', (t) => { | ||
| const input = 'x://user:pass@example.test/a%2Fb?x=a/b?c&y=%23#frag/a?b%23' | ||
| const serialized = fastURI.serialize(fastURI.parse(input)) | ||
| t.equal(serialized, input, 'an already normalized parsed URI round-trips unchanged') | ||
| t.equal( | ||
| fastURI.serialize(fastURI.parse(serialized)), | ||
| serialized, | ||
| 'repeated parse and serialize cycles are stable' | ||
| ) | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| test('IPv6 hosts normalize to the RFC 5952 canonical form', (t) => { | ||
| const cases = [ | ||
| ['http://[0:0:0:0:0:0:0:1]/', 'http://[::1]/'], | ||
| ['http://[0:0:0:0:0:0:0:0]/', 'http://[::]/'], | ||
| ['http://[2001:0db8:0000:0000:0000:0000:0000:0001]/', 'http://[2001:db8::1]/'], | ||
| ['http://[2001:0:0:0:0:0:0:1]/', 'http://[2001::1]/'], | ||
| ['http://[fe80:0:0:0:0:0:0:1]/', 'http://[fe80::1]/'], | ||
| ['http://[1:0:0:0:2:0:0:3]/', 'http://[1::2:0:0:3]/'] | ||
| ] | ||
| for (const [uri, normalized] of cases) { | ||
| t.equal(fastURI.normalize(uri), normalized, `${uri} normalizes to ${normalized}`) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('IPv6 equal() matches the same address across compressed and expanded forms', (t) => { | ||
| const pairs = [ | ||
| ['http://[::1]/', 'http://[0:0:0:0:0:0:0:1]/'], | ||
| ['http://[::]/', 'http://[0:0:0:0:0:0:0:0]/'], | ||
| ['http://[2001:db8::1]/', 'http://[2001:0db8:0000:0000:0000:0000:0000:0001]/'], | ||
| ['http://[1::2:0:0:3]/', 'http://[1:0:0:0:2:0:0:3]/'] | ||
| ] | ||
| for (const [a, b] of pairs) { | ||
| t.equal(fastURI.equal(a, b), true, `${a} equals ${b}`) | ||
| } | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| const HOST_ERROR = 'URI host is malformed.' | ||
| const malformedLiterals = [ | ||
| '::not-valid', | ||
| 'fc00::not-hex', | ||
| 'fe80::not-hex', | ||
| '1:2:3', | ||
| '1:2:3:4:5:6:7', | ||
| '1:2:3:4:5:6:7:8:9', | ||
| '1::2::3', | ||
| '1:::2', | ||
| ':::1', | ||
| '12345::', | ||
| '1:2:3:4:5:6:7::8', | ||
| '::ffff:192.0.2.999', | ||
| '::ffff:192.0.2', | ||
| '::ffff:192.168.001.1', | ||
| '::192.0.2.1:1', | ||
| '1:2:3:4:5:192.0.2.1::', | ||
| 'v.foo', | ||
| 'v1.', | ||
| 'v1.foo%25bar', | ||
| 'v1.K', | ||
| 'fe80::1%25', | ||
| 'fe80::1%25eth 0', | ||
| 'fe80::1%25eth%ZZ', | ||
| 'fe80::1%25K', | ||
| 'not-an-ip' | ||
| ] | ||
| test('malformed bracketed IP literals fail without being rewritten', (t) => { | ||
| for (const literal of malformedLiterals) { | ||
| const uri = `http://[${literal}]/private` | ||
| const parsed = fastURI.parse(uri) | ||
| t.equal(parsed.error, HOST_ERROR, `parse rejects ${literal}`) | ||
| t.equal(parsed.host, `[${literal.toLowerCase()}]`, `parse does not truncate ${literal}`) | ||
| t.equal(fastURI.normalize(uri), uri, `normalize preserves ${literal}`) | ||
| t.equal(fastURI.equal(uri, uri), false, `equal rejects ${literal}`) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('resolve throws for malformed bracketed IP literals', (t) => { | ||
| for (const literal of malformedLiterals) { | ||
| const uri = `http://[${literal}]/private` | ||
| t.throws( | ||
| () => fastURI.resolve(uri, 'child'), | ||
| /URI host is malformed\./, | ||
| `rejects malformed base ${literal}` | ||
| ) | ||
| t.throws( | ||
| () => fastURI.resolve('http://example.com/', uri), | ||
| /URI host is malformed\./, | ||
| `rejects malformed relative input ${literal}` | ||
| ) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('valid IPv6, IPvFuture, embedded IPv4, and zone forms normalize safely', (t) => { | ||
| const cases = [ | ||
| ['http://[::]/', 'http://[::]/', '::'], | ||
| ['http://[::1]/', 'http://[::1]/', '::1'], | ||
| ['http://[1::]/', 'http://[1::]/', '1::'], | ||
| ['http://[2001:0DB8::0001]/', 'http://[2001:db8::1]/', '2001:db8::1'], | ||
| ['http://[0:0:0:0:0:0:0:0]/', 'http://[::]/', '::'], | ||
| ['http://[::ffff:192.0.2.1]/', 'http://[::ffff:192.0.2.1]/', '::ffff:192.0.2.1'], | ||
| ['http://[1:2:3:4:5:6:192.0.2.1]/', 'http://[1:2:3:4:5:6:192.0.2.1]/', '1:2:3:4:5:6:192.0.2.1'], | ||
| ['http://[fe80::A%25EN1]/', 'http://[fe80::a%25EN1]/', 'fe80::a%EN1'], | ||
| ['http://[fe80::a%en1]/', 'http://[fe80::a%25en1]/', 'fe80::a%en1'], | ||
| ['http://[fe80::a%25eth%2D0]/', 'http://[fe80::a%25eth%2D0]/', 'fe80::a%eth%2D0'], | ||
| ['http://[v1.example]/', 'http://[v1.example]/', '[v1.example]'], | ||
| ['http://[vF.A:b]/', 'http://[vf.a:b]/', '[vf.a:b]'] | ||
| ] | ||
| for (const [uri, normalized, host] of cases) { | ||
| const parsed = fastURI.parse(uri) | ||
| t.equal(parsed.error, undefined, `${uri} parses without error`) | ||
| t.equal(parsed.host, host, `${uri} has the expected host`) | ||
| t.equal(fastURI.normalize(uri), normalized, `${uri} normalizes safely`) | ||
| } | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| const MALFORMED_PERCENT_ERROR = 'URI contains malformed percent-encoding.' | ||
| const malformedComponents = [ | ||
| ['path', 'x://example.test/a%'], | ||
| ['path with one trailing hex digit', 'x://example.test/a%2'], | ||
| ['path with non-hex digits', 'x://example.test/a%GG'], | ||
| ['host', 'x://exa%mple.test/path'], | ||
| ['userinfo', 'x://us%er@example.test/path'], | ||
| ['query', 'x://example.test/path?q=%G0'], | ||
| ['fragment', 'x://example.test/path#frag%1'] | ||
| ] | ||
| test('parse reports malformed percent syntax in generic URI components', (t) => { | ||
| for (const [component, uri] of malformedComponents) { | ||
| t.equal(fastURI.parse(uri).error, MALFORMED_PERCENT_ERROR, component) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('normalize preserves malformed percent input unchanged', (t) => { | ||
| for (const [component, uri] of malformedComponents) { | ||
| t.equal(fastURI.normalize(uri), uri, component) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('equal does not equate malformed input with repaired valid input', (t) => { | ||
| const cases = [ | ||
| ['x://example.test/a%', 'x://example.test/a%25'], | ||
| ['x://exa%mple.test/path', 'x://exa%25mple.test/path'], | ||
| ['x://us%er@example.test/path', 'x://us%25er@example.test/path'], | ||
| ['x://example.test/path?q=%', 'x://example.test/path?q=%25'], | ||
| ['x://example.test/path#%', 'x://example.test/path#%25'] | ||
| ] | ||
| for (const [malformed, repaired] of cases) { | ||
| t.equal(fastURI.equal(malformed, repaired, {}), false, malformed) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('resolve rejects malformed percent syntax in either input', (t) => { | ||
| t.throws( | ||
| () => fastURI.resolve('x://example.test/base%', 'child'), | ||
| /URI contains malformed percent-encoding\./, | ||
| 'malformed base' | ||
| ) | ||
| t.throws( | ||
| () => fastURI.resolve('x://example.test/base', 'child%'), | ||
| /URI contains malformed percent-encoding\./, | ||
| 'malformed relative reference' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('valid percent octets remain valid without UTF-8 validation', (t) => { | ||
| const uri = '/%ff?q=%80#%fe' | ||
| const parsed = fastURI.parse(uri) | ||
| t.equal(parsed.error, undefined, 'non-UTF-8 octets are valid percent syntax') | ||
| t.equal(parsed.path, '/%FF', 'path octet is preserved and hex is uppercased') | ||
| t.equal(parsed.query, 'q=%80', 'query octet is preserved') | ||
| t.equal(parsed.fragment, '%FE', 'fragment octet is preserved and hex is uppercased') | ||
| t.equal(fastURI.normalize(uri), '/%FF?q=%80#%FE', 'normalization preserves the octets') | ||
| const allGenericComponents = fastURI.parse('x://u%2f@exa%2fmple.test/%2f?q=%2f#%2f') | ||
| t.equal(allGenericComponents.error, undefined, 'valid escapes are accepted in every generic component') | ||
| const ipv6Zone = fastURI.parse('//[2001:db8::7%en0]') | ||
| t.equal(ipv6Zone.error, undefined, 'historically accepted raw IPv6 zone separator is unchanged') | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| const malformedURNs = [ | ||
| 'urn:', | ||
| 'URN:', | ||
| 'urn:foo', | ||
| 'urn::foo', | ||
| 'urn:foo:', | ||
| 'urn:%66oo:bar' | ||
| ] | ||
| test('parse reports malformed ordinary URNs', (t) => { | ||
| for (const uri of malformedURNs) { | ||
| t.match(fastURI.parse(uri).error, /^URN can not be parsed\.?$/, uri) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('normalize preserves malformed ordinary URNs without throwing', (t) => { | ||
| for (const uri of malformedURNs) { | ||
| t.doesNotThrow(() => fastURI.normalize(uri), `${uri} does not throw`) | ||
| t.equal(fastURI.normalize(uri), uri, `${uri} is preserved`) | ||
| } | ||
| t.equal( | ||
| fastURI.normalize('urn:foo', { reference: 'relative' }), | ||
| 'urn:foo', | ||
| 'an earlier parse error does not hide the missing URN nid' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('equal returns false for malformed ordinary URNs', (t) => { | ||
| for (const uri of malformedURNs) { | ||
| t.equal(fastURI.equal(uri, uri, {}), false, `${uri} is not equal to itself as malformed input`) | ||
| t.equal(fastURI.equal(uri, 'urn:foo:bar', {}), false, `${uri} is not equal to a valid URN`) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('resolve handles malformed ordinary URNs without throwing', (t) => { | ||
| for (const uri of malformedURNs) { | ||
| t.doesNotThrow(() => fastURI.resolve('uri://base/', uri), `${uri} does not throw as a relative reference`) | ||
| t.doesNotThrow(() => fastURI.resolve(uri, ''), `${uri} does not throw as a base URI`) | ||
| } | ||
| // resolve preserves the malformed scheme-specific input rather than surfacing | ||
| // an uncaught 'URN without nid cannot be serialized' error (matches upstream uri-js) | ||
| t.equal(fastURI.resolve('uri://base/', 'urn:'), 'urn:', 'malformed relative URN is preserved') | ||
| t.equal(fastURI.resolve('URN:', ''), 'urn:', 'scheme case is normalized') | ||
| t.equal(fastURI.resolve('uri://base/', 'urn:%66oo:bar'), 'urn:foo:bar', 'percent-encoding is decoded') | ||
| t.end() | ||
| }) | ||
| test('valid URNs retain their existing behavior', (t) => { | ||
| t.equal(fastURI.normalize('URN:FOO:a123,456'), 'urn:foo:a123,456') | ||
| t.equal(fastURI.equal('urn:foo:a123,456', 'URN:FOO:a123,456', {}), true) | ||
| t.equal(fastURI.resolve('uri://base/', 'urn:'), 'urn:', 'default resolve behavior is unchanged') | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| test('query percent-encoding is normalized (RFC 3986 §6.2.2)', (t) => { | ||
| // hex digits are uppercased, like the path component already does | ||
| t.equal(fastURI.normalize('x://h/p?a=%2a'), 'x://h/p?a=%2A', 'uppercases hex in query') | ||
| // unreserved characters are decoded | ||
| t.equal(fastURI.normalize('x://h/p?a=%7e%2e'), 'x://h/p?a=~.', 'decodes unreserved in query') | ||
| // reserved characters stay percent-encoded | ||
| t.equal(fastURI.normalize('x://h/p?a=%2F'), 'x://h/p?a=%2F', 'keeps reserved encoded in query') | ||
| // raw characters that are not allowed are percent-encoded | ||
| t.equal(fastURI.normalize('x://h/p?a b'), 'x://h/p?a%20b', 'encodes a raw space in query') | ||
| t.equal(fastURI.normalize('x://h/p?café'), 'x://h/p?caf%C3%A9', 'encodes raw non-ASCII in query') | ||
| // `?` is allowed unencoded in a query | ||
| t.equal(fastURI.normalize('x://h/p?a?b'), 'x://h/p?a?b', 'keeps `?` raw in query') | ||
| t.end() | ||
| }) | ||
| test('fragment percent-encoding is normalized without decoding reserved characters', (t) => { | ||
| // reserved characters must NOT be decoded — `%2F` is not `/` | ||
| t.equal(fastURI.normalize('x://h/p#a%2Fb%2A'), 'x://h/p#a%2Fb%2A', 'keeps reserved encoded in fragment') | ||
| // hex is uppercased and unreserved is decoded | ||
| t.equal(fastURI.normalize('x://h/p#a%2a%7e'), 'x://h/p#a%2A~', 'uppercases hex and decodes unreserved in fragment') | ||
| // raw characters are still encoded | ||
| t.equal(fastURI.normalize('x://h/p#a b'), 'x://h/p#a%20b', 'encodes a raw space in fragment') | ||
| // `?` and `/` are allowed unencoded in a fragment | ||
| t.equal(fastURI.normalize('x://h/p#f?x/y'), 'x://h/p#f?x/y', 'keeps `?` and `/` raw in fragment') | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| const PATH_RESERVED = "!$&'()*+,;=:@/" | ||
| function percentEncode (character, lowerCase) { | ||
| const hex = character.charCodeAt(0).toString(16).padStart(2, '0') | ||
| return '%' + (lowerCase ? hex : hex.toUpperCase()) | ||
| } | ||
| test('normalize preserves literal and escaped reserved path characters', (t) => { | ||
| t.equal( | ||
| fastURI.normalize('http://example.com/a;b'), | ||
| 'http://example.com/a;b', | ||
| 'literal semicolon remains literal' | ||
| ) | ||
| t.equal( | ||
| fastURI.normalize('http://example.com/a%3ab'), | ||
| 'http://example.com/a%3Ab', | ||
| 'escaped colon remains escaped and its hex is uppercased' | ||
| ) | ||
| for (const character of PATH_RESERVED) { | ||
| const literal = `http://example.com/a${character}b` | ||
| const escaped = `http://example.com/a${percentEncode(character, true)}b` | ||
| const normalizedEscape = `http://example.com/a${percentEncode(character, false)}b` | ||
| t.equal(fastURI.normalize(literal), literal, `preserves literal ${character}`) | ||
| t.equal(fastURI.normalize(escaped), normalizedEscape, `preserves escaped ${character}`) | ||
| } | ||
| t.equal( | ||
| fastURI.normalize('http://example.com/a/./café'), | ||
| 'http://example.com/a/caf%C3%A9', | ||
| 'removes real dot segments and UTF-8 encodes raw non-ASCII' | ||
| ) | ||
| t.equal( | ||
| fastURI.normalize('http://example.com/a/%2e%2e/b'), | ||
| 'http://example.com/a/%2E%2E/b', | ||
| 'preserves escaped dots as path data' | ||
| ) | ||
| t.equal( | ||
| fastURI.normalize('http://example.com/Kſ'), | ||
| 'http://example.com/%E2%84%AA%C5%BF', | ||
| 'UTF-8 encodes Unicode characters that case-fold to ASCII' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('serialize uses the RFC 3986 path character set without opening escapes', (t) => { | ||
| const rawPath = `/a${PATH_RESERVED}b` | ||
| t.equal( | ||
| fastURI.serialize({ scheme: 'http', host: 'example.com', path: rawPath }), | ||
| `http://example.com${rawPath}`, | ||
| 'keeps all legal raw path characters' | ||
| ) | ||
| const escapedPath = Array.from(PATH_RESERVED, (character) => percentEncode(character, true)).join('') | ||
| const normalizedEscapedPath = Array.from(PATH_RESERVED, (character) => percentEncode(character, false)).join('') | ||
| t.equal( | ||
| fastURI.serialize({ scheme: 'http', host: 'example.com', path: '/' + escapedPath }), | ||
| 'http://example.com/' + normalizedEscapedPath, | ||
| 'uppercases valid escapes without decoding reserved characters' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ scheme: 'http', host: 'example.com', path: '/a?b#c[d]' }), | ||
| 'http://example.com/a%3Fb%23c%5Bd%5D', | ||
| 'encodes characters that would leave the path component' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ path: 'a:b/c:d' }), | ||
| 'a%3Ab/c:d', | ||
| 'only escapes a colon where path-noscheme requires it' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ path: './a:b/c:d' }), | ||
| 'a%3Ab/c:d', | ||
| 'escapes a first-segment colon exposed by dot-segment removal' | ||
| ) | ||
| t.equal( | ||
| fastURI.normalize('./a:b'), | ||
| 'a%3Ab', | ||
| 'normalization keeps a relative path from becoming a scheme' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ path: '/Kſ' }), | ||
| '/%E2%84%AA%C5%BF', | ||
| 'UTF-8 encodes Unicode characters that case-fold to ASCII' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('equal distinguishes escaped reserved path data from literal syntax', (t) => { | ||
| for (const character of PATH_RESERVED) { | ||
| const literal = `http://example.com/a${character}b` | ||
| const escaped = `http://example.com/a${percentEncode(character, false)}b` | ||
| t.equal(fastURI.equal(literal, escaped, {}), false, `distinguishes literal and escaped ${character}`) | ||
| } | ||
| t.equal( | ||
| fastURI.equal('./a:b', 'a:b', {}), | ||
| false, | ||
| 'does not equate a relative path with an absolute URI after removing dot segments' | ||
| ) | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.' | ||
| const malformedSchemes = [ | ||
| '%2f%2fevil.example:/pwn', | ||
| '%u002f%u002fevil.example:/pwn', | ||
| '%0d%0aSet-Cookie:%20sid=attacker:/p', | ||
| 'foo%3Abar:value', | ||
| 'foo%2Fbar:value', | ||
| '1http://example.com/', | ||
| 'foo_bar:value', | ||
| 'éxample:value', | ||
| 'Kttp://example.com/', | ||
| 'ſcheme:value' | ||
| ] | ||
| test('parse validates the decoded scheme against RFC 3986', (t) => { | ||
| const validSchemes = [ | ||
| ['a:value', 'a'], | ||
| ['HTTP://example.com/', 'http'], | ||
| ['a1+.-:value', 'a1+.-'], | ||
| ['%4Aavascript:alert(1)', 'javascript'], | ||
| ['foo%2Bbar:value', 'foo+bar'], | ||
| ['%u006Aavascript:1', 'javascript'], | ||
| ['ht%74ps://example.com/', 'https'] | ||
| ] | ||
| for (const [uri, scheme] of validSchemes) { | ||
| const parsed = fastURI.parse(uri) | ||
| t.equal(parsed.error, undefined, uri) | ||
| t.equal(parsed.scheme, scheme, uri + ' scheme') | ||
| } | ||
| for (const uri of malformedSchemes) { | ||
| const parsed = fastURI.parse(uri) | ||
| t.equal(parsed.error, MALFORMED_SCHEME_ERROR, uri) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('decoded schemes select their scheme handlers', (t) => { | ||
| t.equal( | ||
| fastURI.normalize('ht%74ps://example.com:443'), | ||
| 'https://example.com/', | ||
| 'HTTP normalization runs after decoding the scheme' | ||
| ) | ||
| // 3.x has no mailto scheme handler (added in v4); use the ws handler to | ||
| // verify that a percent-encoded scheme still selects its scheme handler | ||
| const ws = fastURI.parse('we%62socket://example.com/') | ||
| t.equal(ws.scheme, 'websocket', 'ws parsing runs after decoding the scheme') | ||
| t.end() | ||
| }) | ||
| test('normalize preserves schemes that decode to invalid identifiers', (t) => { | ||
| for (const uri of malformedSchemes) { | ||
| t.equal(fastURI.normalize(uri), uri, uri) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('scheme normalization cannot introduce authority or control delimiters', (t) => { | ||
| const authority = '%2f%2fevil.example:/pwn' | ||
| const crlf = '%0d%0aSet-Cookie:%20sid=attacker:/p' | ||
| t.equal(fastURI.parse(authority).host, undefined, 'original input has no authority') | ||
| t.equal(fastURI.normalize(authority), authority, 'normalization does not create an authority') | ||
| t.equal(fastURI.normalize(crlf), crlf, 'normalization does not emit raw CRLF') | ||
| t.equal(fastURI.normalize(crlf).includes('\r\n'), false, 'normalized output contains no raw CRLF') | ||
| t.end() | ||
| }) | ||
| test('equal returns false for malformed decoded schemes', (t) => { | ||
| for (const uri of malformedSchemes) { | ||
| t.equal(fastURI.equal(uri, uri, {}), false, uri) | ||
| } | ||
| t.end() | ||
| }) | ||
| test('resolve rejects malformed decoded schemes in either input', (t) => { | ||
| t.throws( | ||
| () => fastURI.resolve('%2f%2fevil.example:/base', 'child'), | ||
| /URI scheme is malformed\./, | ||
| 'malformed base' | ||
| ) | ||
| t.throws( | ||
| () => fastURI.resolve('https://allowed.example/app/', '%2f%2fevil.example:/pwn'), | ||
| /URI scheme is malformed\./, | ||
| 'malformed relative reference' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('serialize validates decoded component schemes', (t) => { | ||
| t.equal( | ||
| fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }), | ||
| 'foo+bar:value', | ||
| 'valid decoded scheme is serialized' | ||
| ) | ||
| t.throws( | ||
| () => fastURI.serialize({ scheme: '//evil.example', path: '/pwn' }), | ||
| /URI scheme is malformed\./, | ||
| 'raw invalid scheme' | ||
| ) | ||
| t.throws( | ||
| () => fastURI.serialize({ scheme: '%2f%2fevil.example', path: '/pwn' }), | ||
| /URI scheme is malformed\./, | ||
| 'encoded invalid scheme' | ||
| ) | ||
| t.equal( | ||
| fastURI.equal( | ||
| { scheme: '%2f%2fevil.example', path: '/pwn' }, | ||
| { scheme: '%2f%2fevil.example', path: '/pwn' }, | ||
| {} | ||
| ), | ||
| false, | ||
| 'equality fails closed for malformed component objects' | ||
| ) | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| test('URN parsing validates the complete scheme-specific input', (t) => { | ||
| const embedded = fastURI.parse('urn:x|foo:bar') | ||
| t.equal(embedded.error, 'URN can not be parsed.', 'does not search for a later nid:nss substring') | ||
| t.equal(embedded.nid, undefined, 'does not adopt the later nid') | ||
| t.equal(embedded.nss, undefined, 'does not adopt the later nss') | ||
| const trailing = fastURI.parse('urn:foo:bar|ignored') | ||
| t.equal(trailing.error, 'URN can not be parsed.', 'rejects trailing input outside the RFC 2141 NSS grammar') | ||
| t.equal(trailing.nid, undefined, 'does not return an nid from a partial match') | ||
| t.equal(trailing.nss, undefined, 'does not return a truncated nss') | ||
| t.end() | ||
| }) | ||
| test('URN parsing preserves the complete RFC 2141 NSS', (t) => { | ||
| const parsed = fastURI.parse('urn:foo:allowed/evil') | ||
| t.equal(parsed.error, undefined, 'accepts slash in an RFC 2141 NSS') | ||
| t.equal(parsed.nid, 'foo', 'parses the nid') | ||
| t.equal(parsed.nss, 'allowed/evil', 'preserves the complete NSS') | ||
| t.equal(fastURI.normalize('urn:foo:allowed/evil'), 'urn:foo:allowed/evil', 'normalization retains slash data') | ||
| t.equal(fastURI.equal('urn:foo:allowed/evil', 'urn:foo:allowed'), false, 'distinct NSS values do not compare equal') | ||
| t.end() | ||
| }) |
| 'use strict' | ||
| const test = require('tape') | ||
| const fastURI = require('..') | ||
| test('WebSocket queries preserve additional question marks', (t) => { | ||
| for (const scheme of ['ws', 'wss']) { | ||
| const uri = `${scheme}://example.com/chat?a?b` | ||
| const truncatedURI = `${scheme}://example.com/chat?a` | ||
| const parsed = fastURI.parse(uri) | ||
| t.equal(parsed.resourceName, '/chat?a?b', `${scheme} parse preserves the resource name`) | ||
| t.equal(fastURI.serialize(parsed), uri, `${scheme} parsed components round-trip`) | ||
| t.equal( | ||
| fastURI.serialize({ scheme, host: 'example.com', resourceName: '/chat?a?b' }), | ||
| uri, | ||
| `${scheme} resource name serializes without truncation` | ||
| ) | ||
| t.equal(fastURI.normalize(uri), uri, `${scheme} normalization preserves the full query`) | ||
| t.equal(fastURI.equal(uri, truncatedURI), false, `${scheme} equality distinguishes the full query`) | ||
| } | ||
| t.end() | ||
| }) |
+201
-48
| 'use strict' | ||
| const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils') | ||
| const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils') | ||
| const { SCHEMES, getSchemeHandler } = require('./lib/schemes') | ||
| const VALID_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/u | ||
| const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.' | ||
| /** | ||
| * @param {string} scheme | ||
| * @returns {string} | ||
| */ | ||
| function decodeValidScheme (scheme) { | ||
| const decodedScheme = unescape(String(scheme)) | ||
| if (!VALID_SCHEME.test(decodedScheme)) { | ||
| throw new TypeError(MALFORMED_SCHEME_ERROR) | ||
| } | ||
| return decodedScheme | ||
| } | ||
| /** | ||
| * @template {import('./types/index').URIComponent|string} T | ||
@@ -29,8 +44,46 @@ * @param {T} uri | ||
| const schemelessOptions = options ? Object.assign({ scheme: 'null' }, options) : { scheme: 'null' } | ||
| const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions) | ||
| const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions) | ||
| if (baseMalformed || relativeMalformed) { | ||
| const { | ||
| parsed: baseParsed, | ||
| malformedAuthorityOrPort: baseMalformed, | ||
| malformedPercentEncoding: baseMalformedPercentEncoding, | ||
| malformedSchemeSpecific: baseMalformedSchemeSpecific, | ||
| malformedHost: baseMalformedHost, | ||
| malformedScheme: baseMalformedScheme | ||
| } = parseWithStatus(baseURI, schemelessOptions) | ||
| const { | ||
| parsed: relativeParsed, | ||
| malformedAuthorityOrPort: relativeMalformed, | ||
| malformedPercentEncoding: relativeMalformedPercentEncoding, | ||
| malformedSchemeSpecific: relativeMalformedSchemeSpecific, | ||
| malformedHost: relativeMalformedHost, | ||
| malformedScheme: relativeMalformedScheme | ||
| } = parseWithStatus(relativeURI, schemelessOptions) | ||
| if ( | ||
| baseMalformed || | ||
| relativeMalformed || | ||
| baseMalformedPercentEncoding || | ||
| relativeMalformedPercentEncoding || | ||
| baseMalformedSchemeSpecific || | ||
| relativeMalformedSchemeSpecific || | ||
| baseMalformedHost || | ||
| relativeMalformedHost || | ||
| baseMalformedScheme || | ||
| relativeMalformedScheme | ||
| ) { | ||
| throw new Error(baseParsed.error || relativeParsed.error || 'URI is malformed.') | ||
| } | ||
| const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true) | ||
| const resolvedSchemeHandler = getSchemeHandler((options && options.scheme) || resolved.scheme) | ||
| const resolvedHost = resolved.host | ||
| const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' && | ||
| (isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6) | ||
| canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP) | ||
| // Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname | ||
| // parser can reject them even though fast-uri preserves them safely as RFC | ||
| // 3986 data. A raw non-ASCII host must still fail closed if conversion fails. | ||
| const encodedASCIIHost = resolvedHost && resolvedHost.indexOf('%') !== -1 && | ||
| !/\P{ASCII}/u.test(resolvedHost) | ||
| if (resolved.error && !encodedASCIIHost) { | ||
| throw new Error(resolved.error) | ||
| } | ||
| schemelessOptions.skipEscape = true | ||
@@ -118,3 +171,3 @@ return serialize(resolved, schemelessOptions) | ||
| return normalizedA !== undefined && normalizedB !== undefined && normalizedA.toLowerCase() === normalizedB.toLowerCase() | ||
| return normalizedA !== undefined && normalizedB !== undefined && normalizedA === normalizedB | ||
| } | ||
@@ -147,2 +200,6 @@ | ||
| if (component.scheme) { | ||
| component.scheme = decodeValidScheme(component.scheme) | ||
| } | ||
| // find scheme handler | ||
@@ -154,9 +211,8 @@ const schemeHandler = getSchemeHandler(options.scheme || component.scheme) | ||
| const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined | ||
| const pathNoScheme = !options.skipEscape && component.scheme === undefined && !hasAuthority | ||
| if (component.path !== undefined) { | ||
| if (!options.skipEscape) { | ||
| component.path = escapePreservingEscapes(component.path) | ||
| if (component.scheme !== undefined) { | ||
| component.path = component.path.split('%3A').join(':') | ||
| } | ||
| component.path = serializePathEncoding(component.path, pathNoScheme) | ||
| } else { | ||
@@ -168,2 +224,4 @@ component.path = normalizePercentEncoding(component.path) | ||
| if (options.reference !== 'suffix' && component.scheme) { | ||
| // Scheme handlers may replace the scheme during serialization. | ||
| component.scheme = decodeValidScheme(component.scheme) | ||
| uriTokens.push(component.scheme, ':') | ||
@@ -191,2 +249,9 @@ } | ||
| // Dot-segment removal can expose a colon that was not originally in the | ||
| // first segment (for example, "./a:b"). Reapply path-noscheme encoding so | ||
| // the serialized relative reference cannot be reparsed as a URI scheme. | ||
| if (pathNoScheme) { | ||
| s = serializePathEncoding(s, true) | ||
| } | ||
| if ( | ||
@@ -205,7 +270,7 @@ authority === undefined && | ||
| if (component.query !== undefined) { | ||
| uriTokens.push('?', component.query) | ||
| uriTokens.push('?', encodeQuery(component.query)) | ||
| } | ||
| if (component.fragment !== undefined) { | ||
| uriTokens.push('#', component.fragment) | ||
| uriTokens.push('#', encodeFragment(component.fragment)) | ||
| } | ||
@@ -248,5 +313,69 @@ return uriTokens.join('') | ||
| /** | ||
| * Checks percent syntax without decoding the represented octets. RFC 3986 | ||
| * percent-encoding is byte-oriented, so sequences such as `%FF` are valid even | ||
| * though they are not independently valid UTF-8. | ||
| * | ||
| * @param {string|undefined} component | ||
| * @returns {boolean} | ||
| */ | ||
| function hasMalformedPercentEncoding (component) { | ||
| if (component === undefined) return false | ||
| let percent = component.indexOf('%') | ||
| while (percent !== -1) { | ||
| if (percent + 2 >= component.length || !/^[\da-f]{2}$/iu.test(component.slice(percent + 1, percent + 3))) { | ||
| return true | ||
| } | ||
| percent = component.indexOf('%', percent + 3) | ||
| } | ||
| return false | ||
| } | ||
| /** | ||
| * @param {RegExpMatchArray} matches | ||
| * @returns {boolean} | ||
| */ | ||
| function hasMalformedComponentPercentEncoding (matches) { | ||
| // Bracketed IP literals use a raw "%" as the zone separator for historical | ||
| // compatibility. Their parsing is intentionally left to normalizeIPv6. | ||
| const host = matches[4] | ||
| return hasMalformedPercentEncoding(matches[3]) || | ||
| (host !== undefined && !(host[0] === '[' && host[host.length - 1] === ']') && hasMalformedPercentEncoding(host)) || | ||
| hasMalformedPercentEncoding(matches[6]) || | ||
| hasMalformedPercentEncoding(matches[7]) || | ||
| hasMalformedPercentEncoding(matches[8]) | ||
| } | ||
| /** | ||
| * @param {import('./types/index').URIComponent} parsed | ||
| * @param {import('./types/index').Options} options | ||
| * @param {{ domainHost?: boolean, unicodeSupport?: boolean }|undefined} schemeHandler | ||
| * @param {boolean} isIP | ||
| * @returns {boolean} whether host conversion failed | ||
| */ | ||
| function canonicalizeHost (parsed, options, schemeHandler, isIP) { | ||
| if ( | ||
| !options.unicodeSupport && | ||
| (!schemeHandler || !schemeHandler.unicodeSupport) && | ||
| parsed.host && | ||
| parsed.host[0] !== '[' && | ||
| (options.domainHost || (schemeHandler && schemeHandler.domainHost)) && | ||
| isIP === false && | ||
| nonSimpleDomain(parsed.host) | ||
| ) { | ||
| try { | ||
| 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 | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
| /** | ||
| * @param {string} uri | ||
| * @param {import('./types/index').Options} [opts] | ||
| * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean }} | ||
| * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }} | ||
| */ | ||
@@ -267,2 +396,7 @@ function parseWithStatus (uri, opts) { | ||
| let malformedAuthorityOrPort = false | ||
| let malformedPercentEncoding = false | ||
| let malformedSchemeSpecific = false | ||
| let malformedHost = false | ||
| let malformedIPLiteral = false | ||
| let malformedScheme = false | ||
@@ -325,2 +459,17 @@ let isIP = false | ||
| if (parsed.scheme !== undefined) { | ||
| const decodedScheme = unescape(parsed.scheme) | ||
| if (VALID_SCHEME.test(decodedScheme)) { | ||
| parsed.scheme = decodedScheme.toLowerCase() | ||
| } else { | ||
| parsed.error = parsed.error || MALFORMED_SCHEME_ERROR | ||
| malformedScheme = true | ||
| } | ||
| } | ||
| malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches) | ||
| if (malformedPercentEncoding) { | ||
| parsed.error = parsed.error || 'URI contains malformed percent-encoding.' | ||
| } | ||
| // fix port number | ||
@@ -340,5 +489,12 @@ if (isNaN(parsed.port)) { | ||
| if (ipv4result === false) { | ||
| const bracketedIPLiteral = parsed.host[0] === '[' && parsed.host[parsed.host.length - 1] === ']' | ||
| const ipv6result = normalizeIPv6(parsed.host) | ||
| parsed.host = ipv6result.host.toLowerCase() | ||
| isIP = ipv6result.isIPV6 | ||
| isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true | ||
| malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true | ||
| parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase() | ||
| if (malformedIPLiteral) { | ||
| parsed.error = parsed.error || 'URI host is malformed.' | ||
| malformedAuthorityOrPort = true | ||
| } | ||
| } else { | ||
@@ -366,24 +522,11 @@ isIP = true | ||
| // check if scheme can't handle IRIs | ||
| if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) { | ||
| // if host component is a domain name | ||
| if (parsed.host && (options.domainHost || (schemeHandler && schemeHandler.domainHost)) && isIP === false && nonSimpleDomain(parsed.host)) { | ||
| // convert Unicode IDN -> ASCII IDN | ||
| try { | ||
| 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 | ||
| } | ||
| } | ||
| // convert IRI -> URI | ||
| } | ||
| // convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts | ||
| malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP) | ||
| if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) { | ||
| if (uri.indexOf('%') !== -1) { | ||
| if (parsed.scheme !== undefined) { | ||
| parsed.scheme = unescape(parsed.scheme) | ||
| if (parsed.host !== undefined && !malformedIPLiteral) { | ||
| const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true) | ||
| parsed.host = reescapeHostDelimiters(host, isIP) | ||
| } | ||
| if (parsed.host !== undefined) { | ||
| parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP) | ||
| } | ||
| } | ||
@@ -393,8 +536,7 @@ if (parsed.path) { | ||
| } | ||
| if (parsed.query) { | ||
| parsed.query = normalizeQueryFragmentEncoding(parsed.query) | ||
| } | ||
| if (parsed.fragment) { | ||
| try { | ||
| parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment)) | ||
| } catch { | ||
| parsed.error = parsed.error || 'URI malformed' | ||
| } | ||
| parsed.fragment = normalizeQueryFragmentEncoding(parsed.fragment) | ||
| } | ||
@@ -406,2 +548,5 @@ } | ||
| schemeHandler.parse(parsed, options) | ||
| if (schemeHandler === SCHEMES.urn && parsed.nid === undefined) { | ||
| malformedSchemeSpecific = true | ||
| } | ||
| } | ||
@@ -411,3 +556,3 @@ } else { | ||
| } | ||
| return { parsed, malformedAuthorityOrPort } | ||
| return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } | ||
| } | ||
@@ -436,9 +581,13 @@ | ||
| * @param {import('./types/index').Options} [opts] | ||
| * @returns {{ normalized: string, malformedAuthorityOrPort: boolean }} | ||
| * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }} | ||
| */ | ||
| function normalizeStringWithStatus (uri, opts) { | ||
| const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts) | ||
| const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts) | ||
| return { | ||
| normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts), | ||
| malformedAuthorityOrPort | ||
| normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts), | ||
| malformedAuthorityOrPort, | ||
| malformedPercentEncoding, | ||
| malformedSchemeSpecific, | ||
| malformedHost, | ||
| malformedScheme | ||
| } | ||
@@ -453,10 +602,14 @@ } | ||
| function normalizeComparableURI (uri, opts) { | ||
| if (typeof uri === 'string') { | ||
| const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts) | ||
| return malformedAuthorityOrPort ? undefined : normalized | ||
| if (typeof uri !== 'string' && typeof uri !== 'object') { | ||
| return undefined | ||
| } | ||
| if (typeof uri === 'object') { | ||
| return serialize(uri, opts) | ||
| let value | ||
| try { | ||
| value = typeof uri === 'string' ? uri : serialize(uri, opts) | ||
| } catch { | ||
| return undefined | ||
| } | ||
| const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts) | ||
| return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized | ||
| } | ||
@@ -463,0 +616,0 @@ |
+9
-4
| 'use strict' | ||
| const { isUUID } = require('./utils') | ||
| const URN_REG = /([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu | ||
| const URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu | ||
@@ -116,5 +116,10 @@ const supportedSchemeNames = /** @type {const} */ (['http', 'https', 'ws', | ||
| if (wsComponent.resourceName) { | ||
| const [path, query] = wsComponent.resourceName.split('?') | ||
| const queryIndex = wsComponent.resourceName.indexOf('?') | ||
| const path = queryIndex === -1 | ||
| ? wsComponent.resourceName | ||
| : wsComponent.resourceName.slice(0, queryIndex) | ||
| wsComponent.path = (path && path !== '/' ? path : undefined) | ||
| wsComponent.query = query | ||
| wsComponent.query = queryIndex === -1 | ||
| ? undefined | ||
| : wsComponent.resourceName.slice(queryIndex + 1) | ||
| wsComponent.resourceName = undefined | ||
@@ -136,3 +141,3 @@ } | ||
| const matches = urnComponent.path.match(URN_REG) | ||
| if (matches) { | ||
| if (matches && matches[0] === urnComponent.path) { | ||
| const scheme = options.scheme || urnComponent.scheme || 'urn' | ||
@@ -139,0 +144,0 @@ urnComponent.nid = matches[1].toLowerCase() |
+386
-89
@@ -16,4 +16,33 @@ 'use strict' | ||
| /** @type {(value: string) => boolean} */ | ||
| const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu) | ||
| const isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u) | ||
| /** @type {(value: string) => boolean} */ | ||
| const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/?]$/u) | ||
| /** @type {(value: string) => boolean} */ | ||
| const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u) | ||
| const BYTE_HEX = new Array(256) | ||
| { | ||
| const HEX_DIGITS = '0123456789ABCDEF' | ||
| for (let i = 0; i < 256; i++) { | ||
| BYTE_HEX[i] = '%' + HEX_DIGITS[i >> 4] + HEX_DIGITS[i & 0xF] | ||
| } | ||
| } | ||
| function percentEncodeNonAscii (cp) { | ||
| if (cp < 0x800) { | ||
| return BYTE_HEX[0xC0 | (cp >> 6)] + | ||
| BYTE_HEX[0x80 | (cp & 0x3F)] | ||
| } | ||
| if (cp < 0x10000) { | ||
| return BYTE_HEX[0xE0 | (cp >> 12)] + | ||
| BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] + | ||
| BYTE_HEX[0x80 | (cp & 0x3F)] | ||
| } | ||
| return BYTE_HEX[0xF0 | (cp >> 18)] + | ||
| BYTE_HEX[0x80 | ((cp >> 12) & 0x3F)] + | ||
| BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] + | ||
| BYTE_HEX[0x80 | (cp & 0x3F)] | ||
| } | ||
| /** | ||
@@ -50,9 +79,11 @@ * @param {Array<string>} input | ||
| /** | ||
| * @typedef {Object} GetIPV6Result | ||
| * @property {boolean} error - Indicates if there was an error parsing the IPv6 address. | ||
| * @property {string} address - The parsed IPv6 address. | ||
| * @property {string} [zone] - The zone identifier, if present. | ||
| */ | ||
| /** @type {(value: string) => boolean} */ | ||
| const isHextet = RegExp.prototype.test.bind(/^[\dA-Fa-f]{1,4}$/) | ||
| /** @type {(value: string) => boolean} */ | ||
| const isIPvFuture = RegExp.prototype.test.bind(/^[vV][\dA-Fa-f]+\.[A-Za-z\d\-._~!$&'()*+,;=:]+$/) | ||
| /** @type {(value: string) => boolean} */ | ||
| const isZoneCharacter = RegExp.prototype.test.bind(/^[A-Za-z\d\-._~]$/) | ||
| /** | ||
@@ -65,7 +96,17 @@ * @param {string} value | ||
| /** | ||
| * @param {Array<string>} buffer | ||
| * @param {string} zone | ||
| * @returns {boolean} | ||
| */ | ||
| function consumeIsZone (buffer) { | ||
| buffer.length = 0 | ||
| function isZoneIdentifier (zone) { | ||
| if (zone.length === 0) return false | ||
| for (let i = 0; i < zone.length; i++) { | ||
| if (isZoneCharacter(zone[i])) continue | ||
| if (zone[i] === '%' && i + 2 < zone.length && isHexPair(zone.slice(i + 1, i + 3))) { | ||
| i += 2 | ||
| continue | ||
| } | ||
| return false | ||
| } | ||
| return true | ||
@@ -75,75 +116,81 @@ } | ||
| /** | ||
| * @param {Array<string>} buffer | ||
| * @param {Array<string>} address | ||
| * @param {GetIPV6Result} output | ||
| * @returns {boolean} | ||
| * Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a | ||
| * single zero hextet is left uncompressed. On ties the leftmost run wins. | ||
| * | ||
| * @param {string[]} hextets | ||
| * @returns {string} | ||
| */ | ||
| function consumeHextets (buffer, address, output) { | ||
| if (buffer.length) { | ||
| const hex = stringArrayToHexStripped(buffer) | ||
| if (hex !== '') { | ||
| address.push(hex) | ||
| function compressIPv6ZeroRun (hextets) { | ||
| let bestStart = -1 | ||
| let bestLength = 0 | ||
| let runStart = -1 | ||
| let runLength = 0 | ||
| for (let i = 0; i < hextets.length; i++) { | ||
| if (hextets[i] === '0') { | ||
| if (runStart === -1) runStart = i | ||
| runLength++ | ||
| if (runLength > bestLength) { | ||
| bestLength = runLength | ||
| bestStart = runStart | ||
| } | ||
| } else { | ||
| output.error = true | ||
| return false | ||
| runStart = -1 | ||
| runLength = 0 | ||
| } | ||
| buffer.length = 0 | ||
| } | ||
| return true | ||
| if (bestLength < 2) return hextets.join(':') | ||
| const head = hextets.slice(0, bestStart).join(':') | ||
| const tail = hextets.slice(bestStart + bestLength).join(':') | ||
| return head + '::' + tail | ||
| } | ||
| /** | ||
| * Validates an IPv6 address against the alternatives in RFC 3986 section | ||
| * 3.2.2 and returns the same address with leading hextet zeroes removed. | ||
| * An embedded IPv4 address counts as two hextets and is only valid at the end. | ||
| * | ||
| * @param {string} input | ||
| * @returns {GetIPV6Result} | ||
| * @returns {string|undefined} | ||
| */ | ||
| function getIPV6 (input) { | ||
| let tokenCount = 0 | ||
| const output = { error: false, address: '', zone: '' } | ||
| /** @type {Array<string>} */ | ||
| const address = [] | ||
| /** @type {Array<string>} */ | ||
| const buffer = [] | ||
| let endipv6Encountered = false | ||
| let endIpv6 = false | ||
| function normalizeIPv6Address (input) { | ||
| const compression = input.indexOf('::') | ||
| if (compression !== -1 && input.indexOf('::', compression + 1) !== -1) return undefined | ||
| let consume = consumeHextets | ||
| const left = compression === -1 ? input.split(':') : input.slice(0, compression).split(':') | ||
| const right = compression === -1 ? [] : input.slice(compression + 2).split(':') | ||
| if (compression !== -1) { | ||
| if (left.length === 1 && left[0] === '') left.length = 0 | ||
| if (right.length === 1 && right[0] === '') right.length = 0 | ||
| } | ||
| for (let i = 0; i < input.length; i++) { | ||
| const cursor = input[i] | ||
| if (cursor === '[' || cursor === ']') { continue } | ||
| if (cursor === ':') { | ||
| if (endipv6Encountered === true) { | ||
| endIpv6 = true | ||
| } | ||
| if (!consume(buffer, address, output)) { break } | ||
| if (++tokenCount > 7) { | ||
| // not valid | ||
| output.error = true | ||
| break | ||
| } | ||
| if (i > 0 && input[i - 1] === ':') { | ||
| endipv6Encountered = true | ||
| } | ||
| address.push(':') | ||
| const parts = left.concat(right) | ||
| let hextetCount = 0 | ||
| for (let i = 0; i < parts.length; i++) { | ||
| const part = parts[i] | ||
| if (part === '') return undefined | ||
| if (part.indexOf('.') !== -1) { | ||
| if (i !== parts.length - 1 || (compression !== -1 && right.length === 0) || !isIPv4(part)) return undefined | ||
| hextetCount += 2 | ||
| continue | ||
| } else if (cursor === '%') { | ||
| if (!consume(buffer, address, output)) { break } | ||
| // switch to zone detection | ||
| consume = consumeIsZone | ||
| } else { | ||
| buffer.push(cursor) | ||
| continue | ||
| } | ||
| if (!isHextet(part)) return undefined | ||
| parts[i] = parseInt(part, 16).toString(16) | ||
| hextetCount++ | ||
| } | ||
| if (buffer.length) { | ||
| if (consume === consumeIsZone) { | ||
| output.zone = buffer.join('') | ||
| } else if (endIpv6) { | ||
| address.push(buffer.join('')) | ||
| } else { | ||
| address.push(stringArrayToHexStripped(buffer)) | ||
| } | ||
| if (compression === -1) { | ||
| if (hextetCount !== 8) return undefined | ||
| return compressIPv6ZeroRun(parts) | ||
| } | ||
| output.address = address.join('') | ||
| return output | ||
| if (hextetCount >= 8) return undefined | ||
| // expand "::" then re-compress the longest run for a canonical result | ||
| const expanded = parts.slice(0, left.length) | ||
| for (let i = hextetCount; i < 8; i++) expanded.push('0') | ||
| for (let i = left.length; i < parts.length; i++) expanded.push(parts[i]) | ||
| return compressIPv6ZeroRun(expanded) | ||
| } | ||
@@ -156,5 +203,11 @@ | ||
| * @property {boolean} isIPV6 - Indicates if the host is an IPv6 address. | ||
| * @property {boolean} [isIPVFuture] - Indicates if the host is an IPvFuture literal. | ||
| * @property {boolean} [error] - Indicates if a bracketed IP literal is malformed. | ||
| */ | ||
| /** | ||
| * Validates and normalizes a bracketed IP literal. Raw zone separators remain | ||
| * accepted for backwards compatibility, while encoded separators and zone | ||
| * contents follow RFC 6874. | ||
| * | ||
| * @param {string} host | ||
@@ -164,16 +217,33 @@ * @returns {NormalizeIPv6Result} | ||
| function normalizeIPv6 (host) { | ||
| if (findToken(host, ':') < 2) { return { host, isIPV6: false } } | ||
| const ipv6 = getIPV6(host) | ||
| const bracketed = host[0] === '[' && host[host.length - 1] === ']' | ||
| const hasBracket = host[0] === '[' || host[host.length - 1] === ']' | ||
| if (hasBracket && !bracketed) return { host, isIPV6: false, error: true } | ||
| if (!ipv6.error) { | ||
| let newHost = ipv6.address | ||
| let escapedHost = ipv6.address | ||
| if (ipv6.zone) { | ||
| newHost += '%' + ipv6.zone | ||
| escapedHost += '%25' + ipv6.zone | ||
| } | ||
| return { host: newHost, isIPV6: true, escapedHost } | ||
| } else { | ||
| return { host, isIPV6: false } | ||
| let input = bracketed ? host.slice(1, -1) : host | ||
| if (bracketed && isIPvFuture(input)) { | ||
| input = input.toLowerCase() | ||
| return { host: `[${input}]`, escapedHost: input, isIPV6: false, isIPVFuture: true } | ||
| } | ||
| if (findToken(input, ':') < 2) { | ||
| return { host, isIPV6: false, error: bracketed } | ||
| } | ||
| let zoneIdentifier = '' | ||
| const zoneSeparator = input.indexOf('%') | ||
| if (zoneSeparator !== -1) { | ||
| const separatorLength = input.slice(zoneSeparator, zoneSeparator + 3).toLowerCase() === '%25' ? 3 : 1 | ||
| zoneIdentifier = input.slice(zoneSeparator + separatorLength) | ||
| if (!isZoneIdentifier(zoneIdentifier)) return { host, isIPV6: false, error: true } | ||
| input = input.slice(0, zoneSeparator) | ||
| } | ||
| const address = normalizeIPv6Address(input) | ||
| if (address === undefined) return { host, isIPV6: false, error: true } | ||
| return { | ||
| host: address + (zoneIdentifier ? '%' + zoneIdentifier : ''), | ||
| escapedHost: address + (zoneIdentifier ? '%25' + zoneIdentifier : ''), | ||
| isIPV6: true | ||
| } | ||
| } | ||
@@ -303,3 +373,3 @@ | ||
| * Normalizes percent escapes and optionally decodes only unreserved ASCII bytes. | ||
| * Reserved delimiters such as `%2F` and `%2E` stay escaped. | ||
| * Reserved delimiters such as `%2F` stay escaped; `%2E` is unreserved. | ||
| * | ||
@@ -353,3 +423,4 @@ * @param {string} input | ||
| for (let i = 0; i < input.length; i++) { | ||
| if (input[i] === '%' && i + 2 < input.length) { | ||
| const ch = input[i] | ||
| if (ch === '%' && i + 2 < input.length) { | ||
| const hex = input.slice(i + 1, i + 3) | ||
@@ -371,6 +442,21 @@ if (isHexPair(hex)) { | ||
| if (isPathCharacter(input[i])) { | ||
| output += input[i] | ||
| if (isPathCharacter(ch)) { | ||
| output += ch | ||
| } else { | ||
| output += escape(input[i]) | ||
| const code = input.charCodeAt(i) | ||
| if (code < 0x80) { | ||
| output += isEscapeSafe(code) ? ch : BYTE_HEX[code] | ||
| } else if (code < 0xD800 || code > 0xDFFF) { | ||
| output += percentEncodeNonAscii(code) | ||
| } else if (code <= 0xDBFF && i + 1 < input.length) { | ||
| const low = input.charCodeAt(i + 1) | ||
| if (low >= 0xDC00 && low <= 0xDFFF) { | ||
| output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00)) | ||
| i++ | ||
| } else { | ||
| output += percentEncodeNonAscii(0xFFFD) | ||
| } | ||
| } else { | ||
| output += percentEncodeNonAscii(0xFFFD) | ||
| } | ||
| } | ||
@@ -383,2 +469,202 @@ } | ||
| /** | ||
| * Serializes a path without rewriting reserved data. Raw RFC 3986 path | ||
| * characters remain literal, valid escapes are preserved and uppercased, and | ||
| * everything else is UTF-8 percent-encoded. In a path-noscheme, a colon in the | ||
| * first segment must be escaped so the result cannot be parsed as a scheme. | ||
| * | ||
| * @param {string} input | ||
| * @param {boolean} [pathNoScheme=false] | ||
| * @returns {string} | ||
| */ | ||
| function serializePathEncoding (input, pathNoScheme = false) { | ||
| let output = '' | ||
| let firstSegment = pathNoScheme && input[0] !== '/' | ||
| for (let i = 0; i < input.length; i++) { | ||
| const ch = input[i] | ||
| if (ch === '%' && i + 2 < input.length) { | ||
| const hex = input.slice(i + 1, i + 3) | ||
| if (isHexPair(hex)) { | ||
| output += '%' + hex.toUpperCase() | ||
| i += 2 | ||
| continue | ||
| } | ||
| } | ||
| if (ch === '/') { | ||
| firstSegment = false | ||
| } | ||
| if (isPathCharacter(ch) && (ch !== ':' || !firstSegment)) { | ||
| output += ch | ||
| } else { | ||
| const code = input.charCodeAt(i) | ||
| if (code < 0x80) { | ||
| output += BYTE_HEX[code] | ||
| } else if (code < 0xD800 || code > 0xDFFF) { | ||
| output += percentEncodeNonAscii(code) | ||
| } else if (code <= 0xDBFF && i + 1 < input.length) { | ||
| const low = input.charCodeAt(i + 1) | ||
| if (low >= 0xDC00 && low <= 0xDFFF) { | ||
| output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00)) | ||
| i++ | ||
| } else { | ||
| output += percentEncodeNonAscii(0xFFFD) | ||
| } | ||
| } else { | ||
| output += percentEncodeNonAscii(0xFFFD) | ||
| } | ||
| } | ||
| } | ||
| return output | ||
| } | ||
| /** | ||
| * Percent-encodes a URI component using its RFC 3986 literal character set. | ||
| * Existing valid escapes are preserved and normalized to uppercase hex. | ||
| * | ||
| * @param {string} input | ||
| * @param {(value: string) => boolean} isAllowed | ||
| * @returns {string} | ||
| */ | ||
| function encodeComponent (input, isAllowed) { | ||
| let output = '' | ||
| for (let i = 0; i < input.length; i++) { | ||
| const ch = input[i] | ||
| if (ch === '%' && i + 2 < input.length) { | ||
| const hex = input.slice(i + 1, i + 3) | ||
| if (isHexPair(hex)) { | ||
| output += '%' + hex.toUpperCase() | ||
| i += 2 | ||
| continue | ||
| } | ||
| } | ||
| if (isAllowed(ch)) { | ||
| output += ch | ||
| } else { | ||
| const code = input.charCodeAt(i) | ||
| if (code < 0x80) { | ||
| output += BYTE_HEX[code] | ||
| } else if (code < 0xD800 || code > 0xDFFF) { | ||
| output += percentEncodeNonAscii(code) | ||
| } else if (code <= 0xDBFF && i + 1 < input.length) { | ||
| const low = input.charCodeAt(i + 1) | ||
| if (low >= 0xDC00 && low <= 0xDFFF) { | ||
| output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00)) | ||
| i++ | ||
| } else { | ||
| output += percentEncodeNonAscii(0xFFFD) | ||
| } | ||
| } else { | ||
| output += percentEncodeNonAscii(0xFFFD) | ||
| } | ||
| } | ||
| } | ||
| return output | ||
| } | ||
| /** | ||
| * Encodes userinfo while preserving its RFC 3986 §3.2.1 literal characters. | ||
| * In particular, authority delimiters such as `@`, `/`, `?`, and `#` are data. | ||
| * | ||
| * @param {string} input | ||
| * @returns {string} | ||
| */ | ||
| function encodeUserinfo (input) { | ||
| return encodeComponent(input, isUserinfoCharacter) | ||
| } | ||
| /** | ||
| * Encodes query data using the RFC 3986 §3.4 grammar. A literal `#` must be | ||
| * escaped because it would otherwise begin the fragment component. | ||
| * | ||
| * @param {string} input | ||
| * @returns {string} | ||
| */ | ||
| function encodeQuery (input) { | ||
| return encodeComponent(input, isQueryFragmentCharacter) | ||
| } | ||
| /** | ||
| * Encodes fragment data using the RFC 3986 §3.5 grammar. | ||
| * | ||
| * @param {string} input | ||
| * @returns {string} | ||
| */ | ||
| function encodeFragment (input) { | ||
| return encodeComponent(input, isQueryFragmentCharacter) | ||
| } | ||
| function isEscapeSafe (cp) { | ||
| return ( | ||
| (cp >= 0x30 && cp <= 0x39) || | ||
| (cp >= 0x41 && cp <= 0x5A) || | ||
| (cp >= 0x61 && cp <= 0x7A) || | ||
| cp === 0x2A || cp === 0x2B || cp === 0x2D || cp === 0x2E || | ||
| cp === 0x2F || cp === 0x40 || cp === 0x5F | ||
| ) | ||
| } | ||
| /** | ||
| * Normalizes the percent-encoding of a query or fragment component. | ||
| * | ||
| * Like `normalizePathEncoding`, but uses the query/fragment character set | ||
| * (which additionally allows `?`) and decodes `.` since it has no dot-segment | ||
| * meaning outside of a path. | ||
| * | ||
| * @param {string} input | ||
| * @returns {string} | ||
| */ | ||
| function normalizeQueryFragmentEncoding (input) { | ||
| let output = '' | ||
| for (let i = 0; i < input.length; i++) { | ||
| const ch = input[i] | ||
| if (ch === '%' && i + 2 < input.length) { | ||
| const hex = input.slice(i + 1, i + 3) | ||
| if (isHexPair(hex)) { | ||
| const normalizedHex = hex.toUpperCase() | ||
| const decoded = String.fromCharCode(parseInt(normalizedHex, 16)) | ||
| if (isUnreserved(decoded)) { | ||
| output += decoded | ||
| } else { | ||
| output += '%' + normalizedHex | ||
| } | ||
| i += 2 | ||
| continue | ||
| } | ||
| } | ||
| if (isQueryFragmentCharacter(ch)) { | ||
| output += ch | ||
| } else { | ||
| const code = input.charCodeAt(i) | ||
| if (code < 0x80) { | ||
| output += isEscapeSafe(code) ? ch : BYTE_HEX[code] | ||
| } else if (code < 0xD800 || code > 0xDFFF) { | ||
| output += percentEncodeNonAscii(code) | ||
| } else if (code <= 0xDBFF && i + 1 < input.length) { | ||
| const low = input.charCodeAt(i + 1) | ||
| if (low >= 0xDC00 && low <= 0xDFFF) { | ||
| output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00)) | ||
| i++ | ||
| } else { | ||
| output += percentEncodeNonAscii(0xFFFD) | ||
| } | ||
| } else { | ||
| output += percentEncodeNonAscii(0xFFFD) | ||
| } | ||
| } | ||
| } | ||
| return output | ||
| } | ||
| /** | ||
| * Escapes a component while preserving existing valid percent escapes. | ||
@@ -416,3 +702,3 @@ * | ||
| if (component.userinfo !== undefined) { | ||
| uriTokens.push(component.userinfo) | ||
| uriTokens.push(encodeUserinfo(component.userinfo)) | ||
| uriTokens.push('@') | ||
@@ -422,6 +708,12 @@ } | ||
| if (component.host !== undefined) { | ||
| let host = unescape(component.host) | ||
| let host = component.host | ||
| if (!isIPv4(host)) { | ||
| const ipV6res = normalizeIPv6(host) | ||
| if (ipV6res.isIPV6 === true) { | ||
| let ipV6res = normalizeIPv6(host) | ||
| if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) { | ||
| // Decode only unreserved bytes, once. In particular, keep %25 encoded | ||
| // so it cannot introduce a second escape during recomposition. | ||
| host = normalizePercentEncoding(host, true) | ||
| ipV6res = normalizeIPv6(host) | ||
| } | ||
| if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) { | ||
| host = `[${ipV6res.escapedHost}]` | ||
@@ -449,2 +741,7 @@ } else { | ||
| normalizePathEncoding, | ||
| serializePathEncoding, | ||
| normalizeQueryFragmentEncoding, | ||
| encodeUserinfo, | ||
| encodeQuery, | ||
| encodeFragment, | ||
| escapePreservingEscapes, | ||
@@ -451,0 +748,0 @@ removeDotSegments, |
+1
-1
| { | ||
| "name": "fast-uri", | ||
| "description": "Dependency-free RFC 3986 URI toolbox", | ||
| "version": "3.1.5", | ||
| "version": "3.1.6", | ||
| "main": "index.js", | ||
@@ -6,0 +6,0 @@ "type": "commonjs", |
+31
-3
@@ -24,2 +24,32 @@ 'use strict' | ||
| test('URI Equals rejects malformed object input even when identical', (t) => { | ||
| const malformed = { scheme: 'http', host: 'example.com', port: 99999, path: '/x' } | ||
| t.equal(fn(malformed, malformed), false) | ||
| t.end() | ||
| }) | ||
| test('URI Equals preserves case-sensitive components', (t) => { | ||
| const suite = [ | ||
| { pair: ['http://example.com/Admin', 'http://example.com/admin'], result: false }, | ||
| { pair: ['http://example.com/?token=SECRET', 'http://example.com/?token=secret'], result: false }, | ||
| { pair: ['http://example.com/#Section', 'http://example.com/#section'], result: false }, | ||
| { pair: ['http://User@example.com/', 'http://user@example.com/'], result: false }, | ||
| { pair: ['urn:foo:CaseSensitive', 'urn:foo:casesensitive'], result: false }, | ||
| { pair: ['ws://example.com/Chat', 'ws://example.com/chat'], result: false }, | ||
| { pair: ['ws://example.com/?token=SECRET', 'ws://example.com/?token=secret'], result: false }, | ||
| { pair: [{ scheme: 'http', host: 'example.com', path: '/Admin' }, { scheme: 'http', host: 'example.com', path: '/admin' }], result: false } | ||
| ] | ||
| runTest(t, suite) | ||
| t.end() | ||
| }) | ||
| test('URI Equals folds normalized scheme and host case', (t) => { | ||
| const suite = [ | ||
| { pair: ['HTTP://EXAMPLE.COM/resource', 'http://example.com/resource'], result: true }, | ||
| { pair: [{ scheme: 'HTTP', host: 'EXAMPLE.COM', path: '/resource' }, { scheme: 'http', host: 'example.com', path: '/resource' }], result: true } | ||
| ] | ||
| runTest(t, suite) | ||
| t.end() | ||
| }) | ||
| // test('IRI Equals', (t) => { | ||
@@ -70,5 +100,3 @@ // // example from RFC 3987 | ||
| t.throws(() => { | ||
| fn('urn:', 'urn:FOO:a123,456') | ||
| }, 'URN without nid cannot be serialized') | ||
| t.equal(fn('urn:', 'urn:FOO:a123,456'), false, 'malformed URN fails equality safely') | ||
@@ -75,0 +103,0 @@ t.end() |
@@ -56,2 +56,3 @@ [ | ||
| "path": "", | ||
| "error": "URI host is malformed.", | ||
| "reference": "relative" | ||
@@ -246,2 +247,3 @@ } | ||
| "path": "", | ||
| "error": "URI host is malformed.", | ||
| "reference": "relative" | ||
@@ -248,0 +250,0 @@ } |
@@ -153,6 +153,8 @@ 'use strict' | ||
| // malformed percent-encoded fragment must not throw | ||
| // a fragment whose decoded bytes are not valid UTF-8 is still valid | ||
| // percent-encoding (RFC 3986 §2.1 is byte-level), so it is preserved as-is | ||
| // rather than being flagged as malformed | ||
| components = fastURI.parse('http://example.com/#%E0%A4A') | ||
| t.equal(components.error, 'URI malformed', 'malformed fragment errors') | ||
| t.equal(components.fragment, '%E0%A4A', 'malformed fragment is preserved') | ||
| t.equal(components.error, undefined, 'valid percent-encoding is not flagged as malformed') | ||
| t.equal(components.fragment, '%E0%A4A', 'fragment is preserved') | ||
@@ -216,2 +218,3 @@ // all | ||
| t.equal(components.host, '[2001:dbz::7]') | ||
| t.equal(components.error, 'URI host is malformed.') | ||
@@ -321,2 +324,3 @@ // mixed IPv4address & IPv6address | ||
| t.equal(components.host, '[2606:2800:220:1:248:1893:25c8:1946:43209]') | ||
| t.equal(components.error, 'URI host is malformed.') | ||
@@ -323,0 +327,0 @@ components = fastURI.parse('urn:foo:|\\24fpl') |
@@ -40,1 +40,102 @@ 'use strict' | ||
| }) | ||
| test('serialize preserves literal RFC 3986 reserved path characters', (t) => { | ||
| // GHSA-7mh8-fcmq-x23c: literal reserved path chars must not be rewritten to | ||
| // percent escapes by the escape()-style safe set. | ||
| const cases = [ | ||
| 'http://example.com/a;b', | ||
| 'http://example.com/a=b', | ||
| 'http://example.com/a&b', | ||
| 'http://example.com/a$b', | ||
| 'http://example.com/a:b', | ||
| 'http://example.com/a@b' | ||
| ] | ||
| t.plan(cases.length) | ||
| cases.forEach((uri) => { | ||
| t.equal(fastURI.serialize(fastURI.parse(uri)), uri, uri) | ||
| }) | ||
| t.end() | ||
| }) | ||
| test('serialize preserves existing reserved path escapes as data', (t) => { | ||
| // GHSA-7mh8-fcmq-x23c: an existing %3A escape must stay %3A instead of being | ||
| // rewritten into a live colon. | ||
| const cases = [ | ||
| 'http://example.com/a%3Ab', | ||
| 'http://example.com/a%3Bb', | ||
| 'http://example.com/a%3D%3D' | ||
| ] | ||
| t.plan(cases.length) | ||
| cases.forEach((uri) => { | ||
| t.equal(fastURI.serialize(fastURI.parse(uri)), uri, uri) | ||
| }) | ||
| t.end() | ||
| }) | ||
| test('serialize keeps path-noscheme colon escaping', (t) => { | ||
| // Without a scheme a literal colon would be reparsed as a scheme separator, | ||
| // so it must stay percent-escaped while an existing %3A is preserved. | ||
| t.equal(fastURI.serialize({ path: 'foo:bar' }), 'foo%3Abar') | ||
| t.equal(fastURI.serialize({ path: 'a%3Ab' }), 'a%3Ab') | ||
| t.end() | ||
| }) | ||
| test('hostname normalization never decodes an escape more than once', (t) => { | ||
| const encodedLocalhost = 'http://%256c%256f%2563%2561%256c%2568%256f%2573%2574/' | ||
| const encodedLoopback = '//127%252e0%252e0%252e1/private' | ||
| const encodedMetadataAddress = '//169%252E254%252E169%252E254/latest/meta-data/' | ||
| t.equal(fastURI.normalize(encodedLocalhost), encodedLocalhost, 'nested hostname letters remain encoded') | ||
| t.equal(fastURI.normalize(encodedLoopback), encodedLoopback, 'nested IPv4 dots remain encoded') | ||
| t.equal( | ||
| fastURI.resolve('https://safe.example/', encodedLoopback), | ||
| 'https://127%252e0%252e0%252e1/private', | ||
| 'resolve does not turn nested dots into a loopback address' | ||
| ) | ||
| t.equal( | ||
| fastURI.resolve('https://allowed.com/api/v1/', encodedMetadataAddress), | ||
| 'https://169%252e254%252e169%252e254/latest/meta-data/', | ||
| 'resolve does not turn nested dots into a metadata address' | ||
| ) | ||
| t.equal( | ||
| fastURI.normalize('http://allowed.com%255Cevil.com/'), | ||
| 'http://allowed.com%255Cevil.com/', | ||
| 'normalize does not activate a nested backslash' | ||
| ) | ||
| t.equal( | ||
| fastURI.serialize({ scheme: 'http', host: '%256cocalhost', path: '/' }), | ||
| 'http://%256cocalhost/', | ||
| 'component serialization preserves an encoded percent sign' | ||
| ) | ||
| t.equal( | ||
| fastURI.equal(encodedLocalhost, 'http://localhost/', {}), | ||
| false, | ||
| 'nested escapes do not compare equal to their twice-decoded target' | ||
| ) | ||
| t.end() | ||
| }) | ||
| test('hostname normalization decodes only current unreserved escapes', (t) => { | ||
| t.equal(fastURI.normalize('x://%6cocalhost/'), 'x://localhost/', 'a current unreserved escape is decoded') | ||
| t.equal(fastURI.normalize('x://%256cocalhost/'), 'x://%256cocalhost/', 'an encoded percent is preserved') | ||
| t.equal(fastURI.normalize('x://host%2540evil/'), 'x://host%2540evil/', 'a nested authority delimiter stays inert') | ||
| t.equal(fastURI.normalize('x://%2525/'), 'x://%2525/', 'nested encoded percent signs stay encoded') | ||
| t.end() | ||
| }) | ||
| test('host conversion failures are not treated as comparable URLs', (t) => { | ||
| const malformedHost = 'http://trusted.test%2540evil.test/' | ||
| t.equal(fastURI.normalize(malformedHost), malformedHost, 'normalization preserves the failing input') | ||
| t.equal(fastURI.equal(malformedHost, malformedHost, {}), false, 'equal rejects a failed host conversion') | ||
| t.throws( | ||
| () => fastURI.resolve(malformedHost, 'child', { domainHost: true }), | ||
| /Host's domain name can not be converted to ASCII/, | ||
| 'resolve propagates a host conversion failure' | ||
| ) | ||
| t.end() | ||
| }) |
@@ -128,7 +128,9 @@ 'use strict' | ||
| test('normalize does not double-decode %2540 into a live @', (t) => { | ||
| const result = fastURI.normalize('http://trusted.com%2540evil.com/') | ||
| const input = 'http://trusted.com%2540evil.com/' | ||
| const result = fastURI.normalize(input) | ||
| const parsed = fastURI.parse(result) | ||
| t.plan(1) | ||
| t.notEqual(parsed.host, 'trusted.com@evil.com', 'http://trusted.com%2540evil.com/') | ||
| t.plan(2) | ||
| t.equal(result, input, 'the encoded percent sign is preserved') | ||
| t.notEqual(parsed.host, 'trusted.com@evil.com', input) | ||
| }) | ||
@@ -164,2 +166,72 @@ | ||
| test('resolve canonicalises the host using the final resolved scheme', (t) => { | ||
| const cases = [ | ||
| { | ||
| base: 'http://trusted.example/base', | ||
| relative: '//127\u30020\u30020\u30021/private', | ||
| expected: 'http://127.0.0.1/private', | ||
| expectedHost: '127.0.0.1', | ||
| description: 'scheme-relative loopback host' | ||
| }, | ||
| { | ||
| base: 'https://ex\u00ADample.com/base', | ||
| relative: 'child', | ||
| expected: 'https://example.com/child', | ||
| expectedHost: 'example.com', | ||
| description: 'host inherited from the base' | ||
| }, | ||
| { | ||
| base: 'http://trusted.example/base', | ||
| relative: 'http://ex\u200Bample.com/', | ||
| expected: 'http://example.com/', | ||
| expectedHost: 'example.com', | ||
| description: 'absolute relative reference' | ||
| } | ||
| ] | ||
| t.plan(cases.length * 2) | ||
| cases.forEach(({ base, relative, expected, expectedHost, description }) => { | ||
| const resolved = fastURI.resolve(base, relative) | ||
| t.equal(resolved, expected, description) | ||
| t.equal(fastURI.parse(resolved).host, expectedHost, `${description} reparses consistently`) | ||
| }) | ||
| }) | ||
| test('resolve applies domain canonicalisation only when the effective scheme opts in', (t) => { | ||
| const host = 'ex\u00ADample.com' | ||
| t.plan(2) | ||
| t.equal( | ||
| fastURI.resolve('uri://trusted.example/', `//${host}/`), | ||
| `uri://${host}/`, | ||
| 'an unsupported scheme preserves the host' | ||
| ) | ||
| t.equal( | ||
| fastURI.resolve('http://trusted.example/', `//${host}/`, { unicodeSupport: true }), | ||
| `http://${host}/`, | ||
| 'unicodeSupport preserves the Unicode host' | ||
| ) | ||
| }) | ||
| test('resolve throws when the final scheme cannot canonicalise the host', (t) => { | ||
| const invalidHost = '\u200D.example' | ||
| const cases = [ | ||
| ['http://trusted.example/', `//${invalidHost}/`], | ||
| [`https://${invalidHost}/base`, 'child'], | ||
| ['http://trusted.example/', `http://${invalidHost}/`], | ||
| ['http://trusted.example/', `//${invalidHost}%2Etest/`] | ||
| ] | ||
| t.plan(cases.length) | ||
| cases.forEach(([base, relative]) => { | ||
| t.throws( | ||
| () => fastURI.resolve(base, relative), | ||
| /Host's domain name can not be converted to ASCII/, | ||
| `${base} + ${relative}` | ||
| ) | ||
| }) | ||
| }) | ||
| test('parse rejects a literal backslash in the authority as malformed (RFC 3986)', (t) => { | ||
@@ -166,0 +238,0 @@ // Regression for the host-confusion bypass: a literal "\" is invalid RFC 3986 |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
215561
28.55%44
29.41%5079
30.63%