Sign In

fast-uri

Package Overview
Dependencies
Maintainers
10
Versions
35
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

fast-uri - npm Package Compare versions

Comparing version
3.1.5
to
3.1.6
+105
test/component-safe-serialization.test.js
'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()

@@ -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,

{
"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",

@@ -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