fast-uri 2.4.6 → 2.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ name: Release the package
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ semver:
7
+ description: 'Release bump type'
8
+ required: true
9
+ type: choice
10
+ options:
11
+ - patch
12
+ - minor
13
+ - major
14
+ - prepatch
15
+ - preminor
16
+ - premajor
17
+ - prerelease
18
+ preid:
19
+ description: 'Prerelease identifier (e.g. rc), used by the pre* bump types'
20
+ required: false
21
+ type: string
22
+
23
+ permissions: {}
24
+
25
+ jobs:
26
+ release:
27
+ permissions:
28
+ id-token: write # required for npm provenance via OIDC
29
+ contents: write # required to push the release commit and the tag
30
+ uses: fastify/workflows/.github/workflows/reusable-release.yml@b0a2ab2d8259b6bd574b804cd6874bbd75c3577c
31
+ with:
32
+ semver: ${{ inputs.semver }}
33
+ preid: ${{ inputs.preid }}
34
+ dist-tag: ${{ startsWith(inputs.semver, 'pre') && 'two-next' || 'two' }}
35
+ make-latest: false
package/README.md CHANGED
@@ -17,12 +17,18 @@ All of the above functions can accept an additional options argument that is an
17
17
 
18
18
  Malformed authorities and out-of-range ports are reported through the parsed component's `error` field. `normalize()` leaves malformed string inputs unchanged, and `equal()` returns `false` when either string input is malformed.
19
19
 
20
+ Schemes are never percent-decoded. RFC 3986 does not allow percent-encoding in a scheme, so an input such as `%6Aavascript:alert(1)` is reported as malformed (`error: 'URI scheme is malformed.'`) instead of being parsed as `javascript:`. `normalize()` leaves it unchanged, `equal()` returns `false`, and `resolve()` and `serialize()` throw.
21
+
22
+ For schemes with domain hosts (`http`, `https`, `ws`, `wss`, or any scheme with the `domainHost` option), a host containing an ASCII character that RFC 3986 does not allow in a reg-name (for example `@`, TAB, LF, CR, space, `"`, `<`, `>`, `^`, `` ` ``, `{`, `|`, `}`) is reported as malformed (`error: 'URI host is malformed.'`). It is never passed through a WHATWG URL parser that could treat `@` as a delimiter or strip whitespace. `normalize()` leaves such input unchanged, `equal()` returns `false`, and `resolve()` throws.
23
+
20
24
  * `scheme` (string)
21
25
  Indicates the scheme that the URI should be treated as, overriding the URI's normal scheme parsing behavior.
22
26
 
23
27
  * `reference` (string)
24
28
  If set to `"suffix"`, it indicates that the URI is in the suffix format and the parser will use the option's `scheme` property to determine the URI's scheme.
25
29
 
30
+ A suffix reference is written without its scheme and without the `//` authority marker (RFC 3986 section 4.5). Such a reference is inherently ambiguous: an authority component cannot always be distinguished from a path. When the input carries an authority of the form `host:port` (for example `//javascript:80/x`) or a userinfo containing a colon, the serialized suffix output can begin with text that reparses as a URI scheme (for example `javascript:80/x`). Do not treat suffix output as a trusted absolute URL, and do not render it or use it in a scheme allowlist without first validating it with `parse()`. If you need an unambiguous result, use the default (non-suffix) serialization.
31
+
26
32
  * `tolerant` (boolean, false)
27
33
  If set to `true`, the parser will relax URI resolving rules.
28
34
 
@@ -82,6 +88,8 @@ uri.normalize('http://example.com/a%2Fb')
82
88
 
83
89
  Reserved path escapes such as `%2F` and `%2E` are preserved as path data during normalization and comparison.
84
90
 
91
+ Dot segments in relative references are removed without changing what the reference resolves to: leading `..` segments that cannot be collapsed are kept, a relative path is never turned into a root-relative one, and a first-segment colon exposed by the removal is kept behind a leading `./` so the reference is not reparsed as a scheme (`a/../b` → `b`, `../x` → `../x`, `x/..` → `./`, `a/../a:b` → `./a:b`).
92
+
85
93
  ### Equal
86
94
 
87
95
  ```js
package/index.js CHANGED
@@ -1,21 +1,29 @@
1
1
  'use strict'
2
2
 
3
- const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
3
+ const { normalizeIPv6, removeDotSegments, removeDotSegmentsRelative, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
4
4
  const SCHEMES = require('./lib/schemes')
5
5
 
6
6
  const VALID_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/u
7
7
  const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.'
8
+ const MALFORMED_HOST_ERROR = 'URI host is malformed.'
9
+ // ASCII characters outside the RFC 3986 reg-name set (unreserved, sub-delims
10
+ // and "%" for pct-encoded): C0 controls, space, DEL, and "#/:<>?@[\]^`{|}.
11
+ const FORBIDDEN_DOMAIN_HOST_CHARACTER = /[\0-\x20"#/:<>?@[\\\]^`{|}\x7F]/u
8
12
 
9
13
  /**
14
+ * RFC 3986 section 3.1 does not allow percent-encoding in a scheme, so the
15
+ * scheme is validated as-is and never decoded. Decoding would let inert input
16
+ * such as `%6Aavascript:` become a live `javascript:` scheme.
17
+ *
10
18
  * @param {string} scheme
11
19
  * @returns {string}
12
20
  */
13
- function decodeValidScheme (scheme) {
14
- const decodedScheme = unescape(String(scheme))
15
- if (!VALID_SCHEME.test(decodedScheme)) {
21
+ function validateScheme (scheme) {
22
+ const stringScheme = String(scheme)
23
+ if (!VALID_SCHEME.test(stringScheme)) {
16
24
  throw new TypeError(MALFORMED_SCHEME_ERROR)
17
25
  }
18
- return decodedScheme
26
+ return stringScheme
19
27
  }
20
28
 
21
29
  function normalize (uri, options) {
@@ -64,6 +72,12 @@ function resolve (baseURI, relativeURI, options) {
64
72
  const resolvedHost = resolved.host
65
73
  const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
66
74
  (isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6)
75
+ // Inputs are parsed scheme-neutrally, so delimiters and controls in a domain
76
+ // host are only detected here, once the final scheme is known. They always
77
+ // fail closed, including in percent-encoded hosts.
78
+ if (hasForbiddenDomainHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)) {
79
+ throw new Error(MALFORMED_HOST_ERROR)
80
+ }
67
81
  canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
68
82
  // Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname
69
83
  // parser can reject them even though fast-uri preserves them safely as RFC
@@ -112,14 +126,17 @@ function resolveComponents (base, relative, options, skipNormalization) {
112
126
  if (relative.path.charAt(0) === '/') {
113
127
  target.path = removeDotSegments(relative.path)
114
128
  } else {
115
- if ((base.userinfo !== undefined || base.host !== undefined || base.port !== undefined) && !base.path) {
129
+ const baseHasAuthority = base.userinfo !== undefined || base.host !== undefined || base.port !== undefined
130
+ if (baseHasAuthority && !base.path) {
116
131
  target.path = '/' + relative.path
117
132
  } else if (!base.path) {
118
133
  target.path = relative.path
119
134
  } else {
120
135
  target.path = base.path.slice(0, base.path.lastIndexOf('/') + 1) + relative.path
121
136
  }
122
- target.path = removeDotSegments(target.path)
137
+ target.path = base.scheme === undefined && !baseHasAuthority && target.path[0] !== '/'
138
+ ? removeDotSegmentsRelative(target.path)
139
+ : removeDotSegments(target.path)
123
140
  }
124
141
  target.query = relative.query
125
142
  }
@@ -164,7 +181,7 @@ function serialize (cmpts, opts) {
164
181
  const uriTokens = []
165
182
 
166
183
  if (components.scheme) {
167
- components.scheme = decodeValidScheme(components.scheme)
184
+ components.scheme = validateScheme(components.scheme)
168
185
  }
169
186
 
170
187
  // find scheme handler
@@ -174,7 +191,11 @@ function serialize (cmpts, opts) {
174
191
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(components, options)
175
192
 
176
193
  const hasAuthority = components.userinfo !== undefined || components.host !== undefined || components.port !== undefined
177
- const pathNoScheme = !options.skipEscape && components.scheme === undefined && !hasAuthority
194
+ // A reference with no emitted scheme is path-noscheme: a bare ':' in its
195
+ // first path segment would reparse as a scheme (RFC 3986 3.3/4.2). Enforced
196
+ // under skipEscape too (resolve()), so the reapply below gates on this flag.
197
+ const schemelessReference = (components.scheme === undefined || options.reference === 'suffix') && !hasAuthority
198
+ const pathNoScheme = !options.skipEscape && schemelessReference
178
199
 
179
200
  if (components.path !== undefined) {
180
201
  if (!options.skipEscape) {
@@ -186,7 +207,7 @@ function serialize (cmpts, opts) {
186
207
 
187
208
  if (options.reference !== 'suffix' && components.scheme) {
188
209
  // Scheme handlers may replace the scheme during serialization.
189
- components.scheme = decodeValidScheme(components.scheme)
210
+ components.scheme = validateScheme(components.scheme)
190
211
  uriTokens.push(components.scheme)
191
212
  uriTokens.push(':')
192
213
  }
@@ -207,13 +228,18 @@ function serialize (cmpts, opts) {
207
228
  let s = components.path
208
229
 
209
230
  if (!options.absolutePath && (!schemeHandler || !schemeHandler.absolutePath)) {
210
- s = removeDotSegments(s)
231
+ // A relative-path reference keeps its unresolvable leading ".." segments
232
+ // and must never become root-relative (RFC 3986 section 5.2.4 applies to
233
+ // merged target paths, not to the reference itself).
234
+ s = schemelessReference && s[0] !== '/'
235
+ ? removeDotSegmentsRelative(s)
236
+ : removeDotSegments(s)
211
237
  }
212
238
 
213
239
  // Dot-segment removal can expose a colon that was not originally in the
214
240
  // first segment (for example, "./a:b"). Reapply path-noscheme encoding so
215
241
  // the serialized relative reference cannot be reparsed as a URI scheme.
216
- if (pathNoScheme) {
242
+ if (schemelessReference) {
217
243
  s = serializePathEncoding(s, true)
218
244
  }
219
245
 
@@ -279,6 +305,11 @@ function getParseError (parsed, matches) {
279
305
  * @returns {boolean} whether host conversion failed
280
306
  */
281
307
  function canonicalizeHost (parsed, options, schemeHandler, isIP) {
308
+ if (hasForbiddenDomainHost(parsed, options, schemeHandler, isIP)) {
309
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
310
+ return true
311
+ }
312
+
282
313
  if (
283
314
  !options.unicodeSupport &&
284
315
  (!schemeHandler || !schemeHandler.unicodeSupport) &&
@@ -288,16 +319,62 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
288
319
  isIP === false &&
289
320
  nonSimpleDomain(parsed.host)
290
321
  ) {
322
+ let url
291
323
  try {
292
- parsed.host = new URL('http://' + parsed.host).hostname
324
+ url = new URL('http://' + parsed.host)
293
325
  } catch (e) {
294
326
  parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
295
327
  return true
296
328
  }
329
+ // The WHATWG parser must have read the whole input as a host. Anything
330
+ // else means it found a delimiter fast-uri did not, so its hostname would
331
+ // describe a different authority.
332
+ if (!isHostOnlyURL(url)) {
333
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
334
+ return true
335
+ }
336
+ parsed.host = url.hostname
297
337
  }
298
338
  return false
299
339
  }
300
340
 
341
+ /**
342
+ * Whether a domain host contains an ASCII character that RFC 3986 does not
343
+ * allow in a reg-name (unreserved, sub-delims, or pct-encoded). Such a host is
344
+ * rejected instead of being handed to the WHATWG URL parser, which would treat
345
+ * "@" as a userinfo delimiter (silently dropping the data before it) and strip
346
+ * TAB, LF, and CR. Non-ASCII characters remain allowed for IDNs.
347
+ *
348
+ * @param {import('./types/index').URIComponent} parsed
349
+ * @param {import('./types/index').Options} options
350
+ * @param {{ domainHost?: boolean }|undefined} schemeHandler
351
+ * @param {boolean} isIP
352
+ * @returns {boolean}
353
+ */
354
+ function hasForbiddenDomainHost (parsed, options, schemeHandler, isIP) {
355
+ return Boolean(
356
+ parsed.host &&
357
+ isIP === false &&
358
+ !isIPLiteral(parsed.host) &&
359
+ (options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
360
+ FORBIDDEN_DOMAIN_HOST_CHARACTER.test(parsed.host)
361
+ )
362
+ }
363
+
364
+ /**
365
+ * @param {URL} url
366
+ * @returns {boolean}
367
+ */
368
+ function isHostOnlyURL (url) {
369
+ return url.username === '' &&
370
+ url.password === '' &&
371
+ url.port === '' &&
372
+ url.pathname === '/' &&
373
+ url.search === '' &&
374
+ url.hash === '' &&
375
+ url.hostname !== ''
376
+ }
377
+
301
378
  function hasMalformedPercentEncoding (component) {
302
379
  if (component === undefined) return false
303
380
 
@@ -339,6 +416,18 @@ function hasMalformedComponentPercentEncoding (matches) {
339
416
  hasMalformedPercentEncoding(matches[8])
340
417
  }
341
418
 
419
+ function hasSuffixAuthority (uri) {
420
+ const authorityEnd = uri.search(/[/?#]/)
421
+ const authority = authorityEnd === -1 ? uri : uri.slice(0, authorityEnd)
422
+ const at = authority.lastIndexOf('@')
423
+ const hostPort = authority.slice(at + 1)
424
+
425
+ if (hostPort[0] === '[') return true
426
+
427
+ const colon = hostPort.lastIndexOf(':')
428
+ return colon === -1 || /^\d*$/u.test(hostPort.slice(colon + 1))
429
+ }
430
+
342
431
  function parseWithStatus (uri, opts) {
343
432
  const options = Object.assign({}, opts)
344
433
  const parsed = {
@@ -357,7 +446,17 @@ function parseWithStatus (uri, opts) {
357
446
  let malformedIPLiteral = false
358
447
  let malformedScheme = false
359
448
  let isIP = false
360
- if (options.reference === 'suffix') uri = (options.scheme ? options.scheme + ':' : '') + '//' + uri
449
+ if (options.reference === 'suffix') {
450
+ if (options.scheme) {
451
+ // v2 suffix references traditionally omit both the scheme and the "//"
452
+ // authority marker. Preserve that interpretation for valid authorities,
453
+ // but treat a non-numeric host-port suffix as a path. Otherwise inputs
454
+ // such as "javascript:alert(1)" are emitted as live schemes.
455
+ uri = options.scheme + (hasSuffixAuthority(uri) ? '://' : ':') + uri
456
+ } else {
457
+ uri = '//' + uri
458
+ }
459
+ }
361
460
 
362
461
  // A literal backslash (U+005C) is not a valid RFC 3986 URI character and is
363
462
  // not an authority delimiter. Reject it in the authority rather than
@@ -407,9 +506,8 @@ function parseWithStatus (uri, opts) {
407
506
  parsed.fragment = matches[8]
408
507
 
409
508
  if (parsed.scheme !== undefined) {
410
- const decodedScheme = unescape(parsed.scheme)
411
- if (VALID_SCHEME.test(decodedScheme)) {
412
- parsed.scheme = decodedScheme.toLowerCase()
509
+ if (VALID_SCHEME.test(parsed.scheme)) {
510
+ parsed.scheme = parsed.scheme.toLowerCase()
413
511
  } else {
414
512
  parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
415
513
  malformedScheme = true
@@ -443,7 +541,7 @@ function parseWithStatus (uri, opts) {
443
541
  parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
444
542
 
445
543
  if (malformedIPLiteral) {
446
- parsed.error = parsed.error || 'URI host is malformed.'
544
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
447
545
  malformedAuthorityOrPort = true
448
546
  }
449
547
  } else {
@@ -473,11 +571,21 @@ function parseWithStatus (uri, opts) {
473
571
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
474
572
  }
475
573
 
476
- if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
477
- if (parsed.host !== undefined && !malformedIPLiteral) {
478
- const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
479
- parsed.host = reescapeHostDelimiters(host, isIP)
574
+ if (uri.indexOf('%') !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
575
+ let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
576
+ if (!isIP) {
577
+ // Fold reg-name case after decoding unreserved octets. The second
578
+ // pass only restores uppercase hex in escapes that remain encoded.
579
+ host = normalizePercentEncoding(host.toLowerCase())
480
580
  }
581
+ // Resolution parses both inputs with the internal `null` scheme before
582
+ // the effective scheme is known. Preserve raw host characters for that
583
+ // intermediate step so the final scheme can reject forbidden domain host
584
+ // characters; the resolved host is re-escaped when it is serialized.
585
+ parsed.host = isIP || options.scheme !== 'null' ? reescapeHostDelimiters(host, isIP) : host
586
+ }
587
+
588
+ if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
481
589
  if (parsed.path !== undefined && parsed.path.length) {
482
590
  parsed.path = normalizePathEncoding(parsed.path)
483
591
  }
package/lib/utils.js CHANGED
@@ -233,6 +233,57 @@ function removeDotSegments (input) {
233
233
  return output.join('')
234
234
  }
235
235
 
236
+ /**
237
+ * Removes dot segments from the path of a relative reference (a path that does
238
+ * not start with "/") without changing what it resolves to against any base.
239
+ * Unlike `removeDotSegments`, leading ".." segments that cannot be collapsed
240
+ * are kept, the result never becomes root-relative, and a result that would be
241
+ * empty or start with an empty segment is prefixed with "." (an empty path
242
+ * means "same document" and a leading "//" would introduce an authority).
243
+ *
244
+ * @param {string} path
245
+ * @returns {string}
246
+ */
247
+ function removeDotSegmentsRelative (path) {
248
+ if (path === '' || path[0] === '/') {
249
+ return removeDotSegments(path)
250
+ }
251
+
252
+ const segments = path.split('/')
253
+ const last = segments.length - 1
254
+ /** @type {string[]} */
255
+ const output = []
256
+ let trailingDirectory = false
257
+
258
+ for (let i = 0; i <= last; i++) {
259
+ const segment = segments[i]
260
+ if (segment === '.' || segment === '..') {
261
+ if (segment === '..') {
262
+ if (output.length !== 0 && output[output.length - 1] !== '..') {
263
+ output.pop()
264
+ } else {
265
+ output.push('..')
266
+ }
267
+ }
268
+ trailingDirectory = i === last
269
+ } else {
270
+ output.push(segment)
271
+ }
272
+ }
273
+
274
+ if (trailingDirectory && output[output.length - 1] !== '..') {
275
+ output.push('')
276
+ }
277
+ if (output.length === 0 || output[0] === '' || output[0].includes(':')) {
278
+ // An empty path, a leading empty segment ("//"), or a first segment
279
+ // containing ":" (which would be mistaken for a scheme, RFC 3986 section
280
+ // 4.2) must stay behind a "." segment so the reference keeps its meaning.
281
+ output.unshift('.')
282
+ }
283
+
284
+ return output.join('/')
285
+ }
286
+
236
287
  /**
237
288
  * Re-escape RFC 3986 gen-delims that must not appear literally in the host.
238
289
  * After the URI regex parses, these characters cannot be literal in the host
@@ -657,6 +708,7 @@ module.exports = {
657
708
  encodeFragment,
658
709
  escapePreservingEscapes,
659
710
  removeDotSegments,
711
+ removeDotSegmentsRelative,
660
712
  isIPv4,
661
713
  normalizeIPv6,
662
714
  stringArrayToHexStripped,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fast-uri",
3
3
  "description": "Dependency free RFC 3986 URI toolbox",
4
- "version": "2.4.6",
4
+ "version": "2.4.8",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -0,0 +1,94 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ const MALFORMED_HOST_ERROR = 'URI host is malformed.'
7
+
8
+ const domainHostSchemes = ['http', 'https', 'ws', 'wss']
9
+
10
+ // Hosts containing ASCII characters that RFC 3986 does not allow in a
11
+ // reg-name. Handing them to the WHATWG URL parser would read "@" as a userinfo
12
+ // delimiter (dropping the data before it) and strip TAB, LF and CR.
13
+ const forbiddenHosts = [
14
+ 'b@evil.example',
15
+ 'ev\til.example',
16
+ 'ev\nil.example',
17
+ 'ev\ril.example',
18
+ 'ev\r\nil.example',
19
+ 'a b.example',
20
+ 'a"b.example',
21
+ 'a<b.example',
22
+ 'a>b.example',
23
+ 'a^b.example',
24
+ 'a`b.example',
25
+ 'a{b.example',
26
+ 'a|b.example',
27
+ 'a}b.example',
28
+ 'a\u007Fb.example',
29
+ 'a\u0000b.example'
30
+ ]
31
+
32
+ test('domain hosts with forbidden ASCII characters fail closed', (t) => {
33
+ for (const scheme of domainHostSchemes) {
34
+ for (const host of forbiddenHosts) {
35
+ const uri = `${scheme}://user@${host}/path`
36
+ const label = JSON.stringify(uri)
37
+
38
+ const parsed = fastURI.parse(uri)
39
+ t.equal(parsed.error, MALFORMED_HOST_ERROR, `${label} reports a malformed host`)
40
+ t.notEqual(parsed.host, 'evil.example', `${label} does not report a truncated host`)
41
+
42
+ t.equal(fastURI.normalize(uri), uri, `${label} normalizes to itself`)
43
+ t.equal(fastURI.normalize(uri, { unicodeSupport: true }), uri, `${label} normalizes to itself with unicodeSupport`)
44
+ t.equal(fastURI.equal(uri, uri), false, `${label} is not comparable`)
45
+ t.equal(fastURI.equal(uri, `${scheme}://user@evil.example/path`), false, `${label} does not alias the stripped host`)
46
+
47
+ t.throws(
48
+ () => fastURI.resolve(`${scheme}://trusted.example/`, `//user@${host}/path`),
49
+ /URI host is malformed\./,
50
+ `${label} is rejected by resolve`
51
+ )
52
+ }
53
+ }
54
+ t.end()
55
+ })
56
+
57
+ test('a second "@" cannot move the authority to a different host', (t) => {
58
+ const uri = 'http://a@b@evil.example/'
59
+ const parsed = fastURI.parse(uri)
60
+ t.equal(parsed.userinfo, 'a')
61
+ t.notEqual(parsed.host, 'evil.example', 'host is not silently truncated')
62
+ t.equal(parsed.error, MALFORMED_HOST_ERROR)
63
+ t.equal(fastURI.normalize(uri), uri)
64
+
65
+ t.throws(
66
+ () => fastURI.resolve('http://trusted.example/', '//a@b%41@evil.example/p'),
67
+ /URI host is malformed\./,
68
+ 'percent-encoded data does not bypass the check in resolve'
69
+ )
70
+ t.end()
71
+ })
72
+
73
+ test('the domainHost option applies the same rules to other schemes', (t) => {
74
+ const parsed = fastURI.parse('foo://a@b@evil.example/', { domainHost: true })
75
+ t.equal(parsed.error, MALFORMED_HOST_ERROR)
76
+ t.end()
77
+ })
78
+
79
+ test('valid domain hosts are unaffected', (t) => {
80
+ t.equal(fastURI.normalize('http://Example.COM/'), 'http://example.com/')
81
+ t.equal(fastURI.normalize('http://%65xample.com/'), 'http://example.com/')
82
+ t.equal(fastURI.normalize('http://bücher.de/'), 'http://xn--bcher-kva.de/')
83
+ t.equal(fastURI.normalize('http://bücher.de/', { unicodeSupport: true }), 'http://bücher.de/')
84
+ t.equal(fastURI.parse("http://a!$&'()*+,;=b.example/").error, undefined, 'sub-delims are allowed')
85
+ t.equal(fastURI.parse('http://user:pass@example.com:8080/').host, 'example.com')
86
+ t.equal(fastURI.resolve('http://trusted.example/', '//user@example.com/p'), 'http://user@example.com/p')
87
+ t.end()
88
+ })
89
+
90
+ test('schemes without domain hosts keep encoding reg-name data', (t) => {
91
+ t.equal(fastURI.normalize('foo://a@b@evil.example/'), 'foo://a@b%40evil.example/')
92
+ t.equal(fastURI.resolve('foo://x/', '//a@b@evil.example/p'), 'foo://a@b%40evil.example/p')
93
+ t.end()
94
+ })
@@ -0,0 +1,157 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ const ABSOLUTE_BASES = [
7
+ 'http://h/p/q/r',
8
+ 'http://h/',
9
+ 'foo://h/a/b/',
10
+ 'http://h/p/q/'
11
+ ]
12
+
13
+ const RELATIVE_BASES = ['', 'base', 'relative/base']
14
+
15
+ test('normalize keeps the meaning of relative-path references', (t) => {
16
+ const cases = [
17
+ ['a/../b', 'b'],
18
+ ['x/..', './'],
19
+ ['../x', '../x'],
20
+ ['./../x', '../x'],
21
+ ['a/b/../../..', '..'],
22
+ ['a/./b', 'a/b'],
23
+ ['a/b/..', 'a/'],
24
+ ['.', './'],
25
+ ['..', '..'],
26
+ ['../', '../'],
27
+ ['../../a/./b/../c', '../../a/c'],
28
+ ['a//..', 'a/'],
29
+ ['a/../b?q#f', 'b?q#f']
30
+ ]
31
+ for (const [input, expected] of cases) {
32
+ t.equal(fastURI.normalize(input), expected, `${input} -> ${expected}`)
33
+ }
34
+ t.end()
35
+ })
36
+
37
+ test('normalize never turns a relative-path reference into a root or network path', (t) => {
38
+ t.equal(fastURI.normalize('.//x'), './/x', 'leading empty segment is kept behind "."')
39
+ t.equal(fastURI.normalize('a/..//x'), './/x', 'collapsed prefix does not expose "//"')
40
+ t.equal(fastURI.parse(fastURI.normalize('a/..//x')).host, undefined, 'no authority is introduced')
41
+ t.equal(fastURI.normalize('./a:b'), './a:b', 'first-segment colon is kept behind a "." segment (RFC 3986 section 4.2)')
42
+ t.equal(fastURI.parse(fastURI.normalize('./a:b')).scheme, undefined, 'no scheme is introduced')
43
+ t.end()
44
+ })
45
+
46
+ test('normalize keeps the existing behavior for root-relative and absolute paths', (t) => {
47
+ t.equal(fastURI.normalize('/a/../b'), '/b')
48
+ t.equal(fastURI.normalize('/../x'), '/x')
49
+ t.equal(fastURI.normalize('http://h/a/../../b'), 'http://h/b')
50
+ t.end()
51
+ })
52
+
53
+ test('equal does not alias relative references with different targets', (t) => {
54
+ t.equal(fastURI.equal('../x', 'x'), false)
55
+ t.equal(fastURI.equal('a/../b', '/b'), false)
56
+ t.equal(fastURI.equal('x/..', '/'), false)
57
+ t.equal(fastURI.equal('a/../b', 'b'), true)
58
+ t.end()
59
+ })
60
+
61
+ test('resolving a normalized relative reference matches resolving the original', (t) => {
62
+ for (const base of [...ABSOLUTE_BASES, ...RELATIVE_BASES]) {
63
+ for (const ref of ['a/../b', 'x/..', '../x', './../x', 'a/b/../../..', '.', '..', './/x']) {
64
+ t.equal(
65
+ fastURI.resolve(base, fastURI.normalize(ref)),
66
+ fastURI.resolve(base, ref),
67
+ `${base} + ${ref}`
68
+ )
69
+ }
70
+ }
71
+ t.equal(fastURI.resolve('http://h/p/q/r', fastURI.normalize('a/../b')), 'http://h/p/q/b')
72
+ t.equal(fastURI.resolve('http://h/p/q/r', fastURI.normalize('../x')), 'http://h/p/x')
73
+ t.end()
74
+ })
75
+
76
+ test('property: normalization of relative references is resolution-preserving and idempotent', (t) => {
77
+ const segments = ['a', 'b', '.', '..', '', 'a:b']
78
+ // Deterministic linear congruential generator so failures are reproducible.
79
+ let seed = 0x5ec02
80
+ const next = (n) => {
81
+ seed = (seed * 1103515245 + 12345) % 2147483648
82
+ return Math.floor((seed / 2147483648) * n)
83
+ }
84
+
85
+ let failures = 0
86
+ let checked = 0
87
+ for (let i = 0; i < 4000; i++) {
88
+ const length = 1 + next(6)
89
+ const parts = []
90
+ for (let j = 0; j < length; j++) parts.push(segments[next(segments.length)])
91
+ const ref = parts.join('/')
92
+ // Only relative-path references: empty refs, refs starting with "/", and
93
+ // refs whose first segment contains ":" are not affected by this change.
94
+ if (ref === '' || ref[0] === '/' || fastURI.parse(ref).scheme !== undefined) continue
95
+
96
+ checked++
97
+ const normalized = fastURI.normalize(ref)
98
+ if (fastURI.normalize(normalized) !== normalized) {
99
+ failures++
100
+ t.fail(`not idempotent: ${ref} -> ${normalized} -> ${fastURI.normalize(normalized)}`)
101
+ }
102
+ for (const base of ABSOLUTE_BASES) {
103
+ const expected = fastURI.resolve(base, ref)
104
+ const actual = fastURI.resolve(base, normalized)
105
+ if (actual !== expected) {
106
+ failures++
107
+ t.fail(`${base} + ${ref}: normalized ${normalized} resolves to ${actual}, expected ${expected}`)
108
+ }
109
+ }
110
+ }
111
+
112
+ t.ok(checked > 1000, `checked ${checked} relative references`)
113
+ t.equal(failures, 0, 'no relative reference changed meaning')
114
+ t.end()
115
+ })
116
+
117
+ test('suffix parsing preserves v2 authority semantics', (t) => {
118
+ const options = { reference: 'suffix', scheme: 'http' }
119
+ for (const [input, expected] of [
120
+ ['example.com/path', { host: 'example.com', path: '/path' }],
121
+ ['example.com:8080/path', { host: 'example.com', port: 8080, path: '/path' }],
122
+ ['user:pass@example.com/path', { userinfo: 'user:pass', host: 'example.com', path: '/path' }]
123
+ ]) {
124
+ const parsed = fastURI.parse(input, options)
125
+ for (const [key, value] of Object.entries(expected)) {
126
+ t.equal(parsed[key], value, `${input} preserves ${key}`)
127
+ }
128
+ t.equal(fastURI.normalize(input, options), input, `${input} normalizes unchanged`)
129
+ t.equal(fastURI.serialize(parsed, options), input, `${input} round-trips`)
130
+ }
131
+ t.end()
132
+ })
133
+
134
+ test('normalize does not promote a colon-first relative reference to a scheme (skipEscape)', (t) => {
135
+ // RFC 3986 section 4.2: a first path segment containing ":" must stay
136
+ // protected by a leading "./", or it is mistaken for a scheme. Collapsing
137
+ // the protecting dot segment must not expose the colon. The default path
138
+ // escapes the colon; with skipEscape the colon is emitted raw and the
139
+ // reference is reparsed as absolute (scheme "a"), changing its meaning.
140
+ const colonRefs = ['./a:b', 'a/../a:b']
141
+ for (const ref of colonRefs) {
142
+ const normalized = fastURI.normalize(ref, { skipEscape: true })
143
+ t.equal(
144
+ fastURI.parse(normalized).scheme,
145
+ undefined,
146
+ `${ref} -> ${normalized} must not introduce a scheme`
147
+ )
148
+ for (const base of ABSOLUTE_BASES) {
149
+ t.equal(
150
+ fastURI.resolve(base, normalized, { skipEscape: true }),
151
+ fastURI.resolve(base, ref, { skipEscape: true }),
152
+ `${base} + ${ref}: normalized ${normalized} must resolve the same`
153
+ )
154
+ }
155
+ }
156
+ t.end()
157
+ })
@@ -77,13 +77,13 @@ test('serialize uses the RFC 3986 path character set without opening escapes', (
77
77
  )
78
78
  t.equal(
79
79
  fastURI.serialize({ path: './a:b/c:d' }),
80
- 'a%3Ab/c:d',
81
- 'escapes a first-segment colon exposed by dot-segment removal'
80
+ './a:b/c:d',
81
+ 'a first-segment colon exposed by dot-segment removal is kept behind a "." segment'
82
82
  )
83
83
  t.equal(
84
84
  fastURI.normalize('./a:b'),
85
- 'a%3Ab',
86
- 'normalization keeps a relative path from becoming a scheme'
85
+ './a:b',
86
+ 'normalization keeps a relative path from becoming a scheme without re-encoding the colon'
87
87
  )
88
88
  t.equal(
89
89
  fastURI.serialize({ path: '/Kſ' }),
@@ -107,3 +107,31 @@ test('equal distinguishes escaped reserved path data from literal syntax', (t) =
107
107
  )
108
108
  t.end()
109
109
  })
110
+
111
+ // A suffix reference never emits its scheme token, so it is a scheme-less
112
+ // (path-noscheme) reference: a bare ':' in its first path segment must be
113
+ // escaped, or the serialized output reparses as an absolute URI with a scheme.
114
+ // @see https://github.com/fastify/fast-uri/security/advisories/GHSA-333x-w4r6-6crp
115
+ test('suffix reference escapes a first-segment colon instead of emitting a scheme', (t) => {
116
+ const dangerous = /^(?:javascript|data|vbscript):/i
117
+ const options = { reference: 'suffix', scheme: 'x' }
118
+
119
+ for (const input of ['javascript:alert(1)', '%6Aavascript:alert(1)', 'data:text/html,x', 'vbscript:msgbox(1)']) {
120
+ t.notOk(dangerous.test(fastURI.normalize(input, options)), `${input} normalizes to an inert reference`)
121
+ }
122
+
123
+ t.equal(
124
+ fastURI.normalize('javascript:alert(1)', options),
125
+ 'javascript%3Aalert(1)',
126
+ 'escapes the leading-segment colon to %3A'
127
+ )
128
+ t.notOk(
129
+ dangerous.test(fastURI.resolve('https://a.example/base/', '%6Aavascript:alert(1)', { reference: 'suffix' })),
130
+ 'resolve in suffix mode does not emit a live scheme'
131
+ )
132
+
133
+ // legitimate suffix usage is unchanged
134
+ t.equal(fastURI.normalize('path/to/file', options), 'path/to/file', 'plain path unchanged')
135
+ t.equal(fastURI.normalize('a/b:c', options), 'a/b:c', 'colon in a later segment stays literal')
136
+ t.end()
137
+ })
@@ -15,7 +15,15 @@ const malformedSchemes = [
15
15
  'foo_bar:value',
16
16
  'éxample:value',
17
17
  'Kttp://example.com/',
18
- 'ſcheme:value'
18
+ 'ſcheme:value',
19
+ // RFC 3986 section 3.1 does not allow percent-encoding in a scheme.
20
+ '%4Aavascript:alert(1)',
21
+ '%6aavascript:alert(1)',
22
+ 'foo%2Bbar:value',
23
+ '%u006Aavascript:1',
24
+ 'ht%74ps://example.com/',
25
+ 'ma%69lto:user@example.org',
26
+ '%64ata:text/html,x'
19
27
  ]
20
28
 
21
29
  test('parse validates the decoded scheme against RFC 3986', (t) => {
@@ -23,10 +31,7 @@ test('parse validates the decoded scheme against RFC 3986', (t) => {
23
31
  ['a:value', 'a'],
24
32
  ['HTTP://example.com/', 'http'],
25
33
  ['a1+.-:value', 'a1+.-'],
26
- ['%4Aavascript:alert(1)', 'javascript'],
27
- ['foo%2Bbar:value', 'foo+bar'],
28
- ['%u006Aavascript:1', 'javascript'],
29
- ['ht%74ps://example.com/', 'https']
34
+ ['foo+bar:value', 'foo+bar']
30
35
  ]
31
36
 
32
37
  for (const [uri, scheme] of validSchemes) {
@@ -42,17 +47,41 @@ test('parse validates the decoded scheme against RFC 3986', (t) => {
42
47
  t.end()
43
48
  })
44
49
 
45
- test('decoded schemes select their scheme handlers', (t) => {
50
+ test('percent-encoded schemes do not select scheme handlers', (t) => {
46
51
  t.equal(
47
52
  fastURI.normalize('ht%74ps://example.com:443'),
48
- 'https://example.com/',
49
- 'HTTP normalization runs after decoding the scheme'
53
+ 'ht%74ps://example.com:443',
54
+ 'HTTP normalization does not run for an encoded scheme'
50
55
  )
51
56
 
52
- // 3.x has no mailto scheme handler (added in v4); use the ws handler to
53
- // verify that a percent-encoded scheme still selects its scheme handler
54
- const ws = fastURI.parse('we%62socket://example.com/')
55
- t.equal(ws.scheme, 'websocket', 'ws parsing runs after decoding the scheme')
57
+ // 2.x has no mailto scheme handler (added in v3); use the ws handler to
58
+ // verify that a percent-encoded scheme does not select a scheme handler
59
+ const ws = fastURI.parse('w%73://example.com/?q')
60
+ t.equal(ws.resourceName, undefined, 'ws parsing does not run for an encoded scheme')
61
+ t.equal(ws.error, MALFORMED_SCHEME_ERROR)
62
+ t.end()
63
+ })
64
+
65
+ test('normalization cannot synthesize a scheme from encoded input', (t) => {
66
+ const inputs = [
67
+ '%6Aavascript:alert(1)',
68
+ '%4A%41%56%41%53%43%52%49%50%54:alert(1)',
69
+ '%u006Aavascript:alert(1)',
70
+ 'j%61vascript:alert(1)',
71
+ '%64ata:text/html,<script>alert(1)</script>',
72
+ 'vb%73cript:msgbox(1)'
73
+ ]
74
+
75
+ for (const uri of inputs) {
76
+ const parsed = fastURI.parse(uri)
77
+ t.equal(parsed.error, MALFORMED_SCHEME_ERROR, uri + ' is malformed')
78
+ t.notOk(/^(?:javascript|data|vbscript):/i.test(String(parsed.scheme) + ':'), uri + ' parsed scheme is not decoded')
79
+ t.equal(fastURI.normalize(uri), uri, uri + ' normalizes to itself')
80
+ t.equal(fastURI.equal(uri, uri), false, uri + ' is not comparable')
81
+ t.throws(() => fastURI.resolve('https://example.com/app/', uri), /URI scheme is malformed\./, uri + ' is rejected by resolve')
82
+ }
83
+
84
+ t.equal(fastURI.equal('%68ttp://a/', 'http://a/'), false, 'encoded scheme does not alias a plain scheme')
56
85
  t.end()
57
86
  })
58
87
 
@@ -95,11 +124,21 @@ test('resolve rejects malformed decoded schemes in either input', (t) => {
95
124
  t.end()
96
125
  })
97
126
 
98
- test('serialize validates decoded component schemes', (t) => {
127
+ test('serialize validates component schemes without decoding them', (t) => {
99
128
  t.equal(
100
- fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }),
129
+ fastURI.serialize({ scheme: 'foo+bar', path: 'value' }),
101
130
  'foo+bar:value',
102
- 'valid decoded scheme is serialized'
131
+ 'valid scheme is serialized'
132
+ )
133
+ t.throws(
134
+ () => fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }),
135
+ /URI scheme is malformed\./,
136
+ 'percent-encoded scheme is rejected'
137
+ )
138
+ t.throws(
139
+ () => fastURI.serialize({ scheme: '%6Aavascript', path: 'alert(1)' }),
140
+ /URI scheme is malformed\./,
141
+ 'encoded scheme cannot serialize as javascript:'
103
142
  )
104
143
  t.throws(
105
144
  () => fastURI.serialize({ scheme: '//evil.example', path: '/pwn' }),
@@ -126,6 +126,44 @@ test('hostname normalization decodes only current unreserved escapes', (t) => {
126
126
  t.end()
127
127
  })
128
128
 
129
+ test('scheme-relative hostname normalization folds decoded ASCII case', (t) => {
130
+ const encodedHost = '//%41.com'
131
+ const normalizedHost = fastURI.normalize(encodedHost)
132
+ const encodedMetadata = '//%4Detadata.internal/private'
133
+ const literalMetadata = '//metadata.internal/private'
134
+
135
+ t.equal(fastURI.parse(encodedHost).host, 'a.com', 'parse folds an encoded uppercase host letter')
136
+ t.equal(normalizedHost, '//a.com', 'normalize emits a lowercase host')
137
+ t.equal(fastURI.normalize(normalizedHost), normalizedHost, 'host normalization is idempotent')
138
+ t.equal(fastURI.equal(encodedHost, '//a.com'), true, 'encoded and literal host spellings compare equal')
139
+ t.equal(fastURI.equal(encodedMetadata, literalMetadata), true, 'encoded metadata host compares equal')
140
+ t.equal(
141
+ fastURI.resolve('x://trusted.example/', encodedMetadata),
142
+ fastURI.resolve('x://trusted.example/', literalMetadata),
143
+ 'encoded and literal metadata hosts resolve identically'
144
+ )
145
+ t.equal(
146
+ fastURI.normalize('//example.com%2fpath'),
147
+ '//example.com%2Fpath',
148
+ 'reserved host escapes remain encoded with uppercase hex'
149
+ )
150
+ t.equal(fastURI.normalize('//%2541.com'), '//%2541.com', 'nested host escapes are not decoded twice')
151
+ t.equal(fastURI.equal('//User@%41.com/path', '//user@a.com/path'), false, 'userinfo remains case-sensitive')
152
+ t.equal(fastURI.equal('//%41.com/Path', '//a.com/path'), false, 'path remains case-sensitive')
153
+ t.equal(fastURI.equal('//%41.com/?Token=Value', '//a.com/?token=value'), false, 'query remains case-sensitive')
154
+ t.end()
155
+ })
156
+
157
+ test('host normalization applies to schemes that skip path normalization', (t) => {
158
+ const encodedHost = 'mailto://%41.com'
159
+ const literalHost = 'mailto://a.com'
160
+
161
+ t.equal(fastURI.parse(encodedHost).host, fastURI.parse(literalHost).host, 'parse results have consistent hosts')
162
+ t.equal(fastURI.normalize(encodedHost), fastURI.normalize(literalHost), 'normalize results are consistent')
163
+ t.equal(fastURI.equal(encodedHost, literalHost), true, 'encoded and literal hosts compare equal')
164
+ t.end()
165
+ })
166
+
129
167
  test('host conversion failures are not treated as comparable URLs', (t) => {
130
168
  const malformedHost = 'http://trusted.test%2540evil.test/'
131
169