fast-uri 4.2.2 → 4.2.3

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.
@@ -11,6 +11,14 @@ on:
11
11
  - patch
12
12
  - minor
13
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
14
22
 
15
23
  permissions: {}
16
24
 
@@ -19,6 +27,8 @@ jobs:
19
27
  permissions:
20
28
  id-token: write # required for npm provenance via OIDC
21
29
  contents: write # required to push the release commit and the tag
22
- uses: fastify/workflows/.github/workflows/reusable-release.yml@b1a11bce713bc857e52b2231fec3c89071ad3135
30
+ uses: fastify/workflows/.github/workflows/reusable-release.yml@b0a2ab2d8259b6bd574b804cd6874bbd75c3577c
23
31
  with:
24
32
  semver: ${{ inputs.semver }}
33
+ preid: ${{ inputs.preid }}
34
+ dist-tag: ${{ startsWith(inputs.semver, 'pre') && 'next' || 'latest' }}
package/README.md CHANGED
@@ -15,12 +15,18 @@ All of the above functions can accept an additional options argument that is an
15
15
 
16
16
  Malformed authorities, out-of-range ports, and raw leading or trailing C0 controls or spaces (U+0000–U+0020) are reported through the parsed component's `error` field. `normalize()` leaves these malformed string inputs unchanged, and `equal()` returns `false`. `resolve()` rejects them in either input and rejects HTTP(S) results with an empty host. Percent-encoded and interior component data remain valid.
17
17
 
18
+ 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.
19
+
20
+ 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.
21
+
18
22
  * `scheme` (string)
19
23
  Indicates the scheme that the URI should be treated as, overriding the URI's normal scheme parsing behavior.
20
24
 
21
25
  * `reference` (string)
22
26
  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.
23
27
 
28
+ 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.
29
+
24
30
  * `tolerant` (boolean, false)
25
31
  If set to `true`, the parser will relax URI resolving rules.
26
32
 
@@ -80,6 +86,8 @@ uri.normalize('http://example.com/a%2Fb')
80
86
 
81
87
  Reserved path escapes such as `%2F` and `%2E` are preserved as path data during normalization and comparison.
82
88
 
89
+ 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`).
90
+
83
91
  ### Equal
84
92
 
85
93
  ```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, encodeHost, isIPv4, nonSimpleDomain } = require('./lib/utils')
3
+ const { normalizeIPv6, removeDotSegments, removeDotSegmentsRelative, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, encodeHost, isIPv4, nonSimpleDomain } = require('./lib/utils')
4
4
  const { SCHEMES, getSchemeHandler } = 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
  /**
@@ -88,6 +96,12 @@ function resolve (baseURI, relativeURI, options) {
88
96
  }
89
97
  const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
90
98
  (isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6)
99
+ // Inputs are parsed scheme-neutrally, so delimiters and controls in a domain
100
+ // host are only detected here, once the final scheme is known. They always
101
+ // fail closed, including in percent-encoded hosts.
102
+ if (hasForbiddenDomainHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)) {
103
+ throw new Error(MALFORMED_HOST_ERROR)
104
+ }
91
105
  canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
92
106
  // Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname
93
107
  // parser can reject them even though fast-uri preserves them safely as RFC
@@ -167,14 +181,17 @@ function resolveComponent (base, relative, options, skipNormalization) {
167
181
  if (relative.path[0] === '/') {
168
182
  target.path = removeDotSegments(relative.path)
169
183
  } else {
170
- if ((base.userinfo !== undefined || base.host !== undefined || base.port !== undefined) && !base.path) {
184
+ const baseHasAuthority = base.userinfo !== undefined || base.host !== undefined || base.port !== undefined
185
+ if (baseHasAuthority && !base.path) {
171
186
  target.path = '/' + relative.path
172
187
  } else if (!base.path) {
173
188
  target.path = relative.path
174
189
  } else {
175
190
  target.path = base.path.slice(0, base.path.lastIndexOf('/') + 1) + relative.path
176
191
  }
177
- target.path = removeDotSegments(target.path)
192
+ target.path = base.scheme === undefined && !baseHasAuthority && target.path[0] !== '/'
193
+ ? removeDotSegmentsRelative(target.path)
194
+ : removeDotSegments(target.path)
178
195
  }
179
196
  target.query = relative.query
180
197
  }
@@ -235,7 +252,7 @@ function serialize (cmpts, opts) {
235
252
  const uriTokens = []
236
253
 
237
254
  if (component.scheme) {
238
- component.scheme = decodeValidScheme(component.scheme)
255
+ component.scheme = validateScheme(component.scheme)
239
256
  }
240
257
 
241
258
  // find scheme handler
@@ -245,7 +262,11 @@ function serialize (cmpts, opts) {
245
262
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options)
246
263
 
247
264
  const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
248
- const pathNoScheme = !options.skipEscape && component.scheme === undefined && !hasAuthority
265
+ // A reference with no emitted scheme is path-noscheme: a bare ':' in its
266
+ // first path segment would reparse as a scheme (RFC 3986 3.3/4.2). Enforced
267
+ // under skipEscape too (resolve()), so the reapply below gates on this flag.
268
+ const schemelessReference = (component.scheme === undefined || options.reference === 'suffix') && !hasAuthority
269
+ const pathNoScheme = !options.skipEscape && schemelessReference
249
270
 
250
271
  if (component.path !== undefined) {
251
272
  if (!options.skipEscape) {
@@ -257,7 +278,7 @@ function serialize (cmpts, opts) {
257
278
 
258
279
  if (options.reference !== 'suffix' && component.scheme) {
259
280
  // Scheme handlers may replace the scheme during serialization.
260
- component.scheme = decodeValidScheme(component.scheme)
281
+ component.scheme = validateScheme(component.scheme)
261
282
  uriTokens.push(component.scheme, ':')
262
283
  }
263
284
 
@@ -278,13 +299,18 @@ function serialize (cmpts, opts) {
278
299
  let s = component.path
279
300
 
280
301
  if (!options.absolutePath && (!schemeHandler || !schemeHandler.absolutePath)) {
281
- s = removeDotSegments(s)
302
+ // A relative-path reference keeps its unresolvable leading ".." segments
303
+ // and must never become root-relative (RFC 3986 section 5.2.4 applies to
304
+ // merged target paths, not to the reference itself).
305
+ s = schemelessReference && s[0] !== '/'
306
+ ? removeDotSegmentsRelative(s)
307
+ : removeDotSegments(s)
282
308
  }
283
309
 
284
310
  // Dot-segment removal can expose a colon that was not originally in the
285
311
  // first segment (for example, "./a:b"). Reapply path-noscheme encoding so
286
312
  // the serialized relative reference cannot be reparsed as a URI scheme.
287
- if (pathNoScheme) {
313
+ if (schemelessReference) {
288
314
  s = serializePathEncoding(s, true)
289
315
  }
290
316
 
@@ -404,6 +430,11 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
404
430
  parsed.host[0] === '[' &&
405
431
  parsed.host[parsed.host.length - 1] === ']'
406
432
 
433
+ if (hasForbiddenDomainHost(parsed, options, schemeHandler, isIP)) {
434
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
435
+ return true
436
+ }
437
+
407
438
  if (
408
439
  !options.unicodeSupport &&
409
440
  (!schemeHandler || !schemeHandler.unicodeSupport) &&
@@ -414,17 +445,63 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
414
445
  !bracketedIPLiteral &&
415
446
  nonSimpleDomain(parsed.host)
416
447
  ) {
448
+ let url
417
449
  try {
418
- parsed.host = new URL('http://' + parsed.host).hostname
450
+ url = new URL('http://' + parsed.host)
419
451
  } catch (e) {
420
452
  parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
421
453
  return true
422
454
  }
455
+ // The WHATWG parser must have read the whole input as a host. Anything
456
+ // else means it found a delimiter fast-uri did not, so its hostname would
457
+ // describe a different authority.
458
+ if (!isHostOnlyURL(url)) {
459
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
460
+ return true
461
+ }
462
+ parsed.host = url.hostname
423
463
  }
424
464
 
425
465
  return false
426
466
  }
427
467
 
468
+ /**
469
+ * Whether a domain host contains an ASCII character that RFC 3986 does not
470
+ * allow in a reg-name (unreserved, sub-delims, or pct-encoded). Such a host is
471
+ * rejected instead of being handed to the WHATWG URL parser, which would treat
472
+ * "@" as a userinfo delimiter (silently dropping the data before it) and strip
473
+ * TAB, LF, and CR. Non-ASCII characters remain allowed for IDNs.
474
+ *
475
+ * @param {import('./types/index').URIComponent} parsed
476
+ * @param {import('./types/index').Options} options
477
+ * @param {{ domainHost?: boolean }|undefined} schemeHandler
478
+ * @param {boolean} isIP
479
+ * @returns {boolean}
480
+ */
481
+ function hasForbiddenDomainHost (parsed, options, schemeHandler, isIP) {
482
+ return Boolean(
483
+ parsed.host &&
484
+ isIP === false &&
485
+ !isIPLiteral(parsed.host) &&
486
+ (options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
487
+ FORBIDDEN_DOMAIN_HOST_CHARACTER.test(parsed.host)
488
+ )
489
+ }
490
+
491
+ /**
492
+ * @param {URL} url
493
+ * @returns {boolean}
494
+ */
495
+ function isHostOnlyURL (url) {
496
+ return url.username === '' &&
497
+ url.password === '' &&
498
+ url.port === '' &&
499
+ url.pathname === '/' &&
500
+ url.search === '' &&
501
+ url.hash === '' &&
502
+ url.hostname !== ''
503
+ }
504
+
428
505
  /**
429
506
  * @param {string} uri
430
507
  * @param {import('./types/index').Options} [opts]
@@ -517,9 +594,8 @@ function parseWithStatus (uri, opts) {
517
594
  parsed.fragment = matches[8]
518
595
 
519
596
  if (parsed.scheme !== undefined) {
520
- const decodedScheme = unescape(parsed.scheme)
521
- if (VALID_SCHEME.test(decodedScheme)) {
522
- parsed.scheme = decodedScheme.toLowerCase()
597
+ if (VALID_SCHEME.test(parsed.scheme)) {
598
+ parsed.scheme = parsed.scheme.toLowerCase()
523
599
  } else {
524
600
  parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
525
601
  malformedScheme = true
@@ -563,7 +639,7 @@ function parseWithStatus (uri, opts) {
563
639
  }
564
640
 
565
641
  if (malformedIPLiteral) {
566
- parsed.error = parsed.error || 'URI host is malformed.'
642
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
567
643
  malformedAuthorityOrPort = true
568
644
  }
569
645
  } else {
package/lib/utils.js CHANGED
@@ -352,6 +352,57 @@ function removeDotSegments (path) {
352
352
  return output.join('')
353
353
  }
354
354
 
355
+ /**
356
+ * Removes dot segments from the path of a relative reference (a path that does
357
+ * not start with "/") without changing what it resolves to against any base.
358
+ * Unlike `removeDotSegments`, leading ".." segments that cannot be collapsed
359
+ * are kept, the result never becomes root-relative, and a result that would be
360
+ * empty or start with an empty segment is prefixed with "." (an empty path
361
+ * means "same document" and a leading "//" would introduce an authority).
362
+ *
363
+ * @param {string} path
364
+ * @returns {string}
365
+ */
366
+ function removeDotSegmentsRelative (path) {
367
+ if (path === '' || path[0] === '/') {
368
+ return removeDotSegments(path)
369
+ }
370
+
371
+ const segments = path.split('/')
372
+ const last = segments.length - 1
373
+ /** @type {string[]} */
374
+ const output = []
375
+ let trailingDirectory = false
376
+
377
+ for (let i = 0; i <= last; i++) {
378
+ const segment = segments[i]
379
+ if (segment === '.' || segment === '..') {
380
+ if (segment === '..') {
381
+ if (output.length !== 0 && output[output.length - 1] !== '..') {
382
+ output.pop()
383
+ } else {
384
+ output.push('..')
385
+ }
386
+ }
387
+ trailingDirectory = i === last
388
+ } else {
389
+ output.push(segment)
390
+ }
391
+ }
392
+
393
+ if (trailingDirectory && output[output.length - 1] !== '..') {
394
+ output.push('')
395
+ }
396
+ if (output.length === 0 || output[0] === '' || output[0].includes(':')) {
397
+ // An empty path, a leading empty segment ("//"), or a first segment
398
+ // containing ":" (which would be mistaken for a scheme, RFC 3986 section
399
+ // 4.2) must stay behind a "." segment so the reference keeps its meaning.
400
+ output.unshift('.')
401
+ }
402
+
403
+ return output.join('/')
404
+ }
405
+
355
406
  /**
356
407
  * Encodes a host according to the RFC 3986 host grammar. Valid IP literals
357
408
  * have already been checked by `normalizeIPv6`, so their address separators
@@ -857,6 +908,7 @@ module.exports = {
857
908
  encodeFragment,
858
909
  escapePreservingEscapes,
859
910
  removeDotSegments,
911
+ removeDotSegmentsRelative,
860
912
  isIPv4,
861
913
  isUUID,
862
914
  normalizeIPv6,
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": "4.2.2",
4
+ "version": "4.2.3",
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,152 @@
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 normalization preserves relative path meaning', (t) => {
118
+ const options = { reference: 'suffix', scheme: 'x' }
119
+ for (const [input, expected] of [
120
+ ['a/../b', 'b'],
121
+ ['x/..', './'],
122
+ ['a/../a:b', './a:b']
123
+ ]) {
124
+ t.equal(fastURI.normalize(input, options), expected, `${input} -> ${expected}`)
125
+ }
126
+ t.end()
127
+ })
128
+
129
+ test('normalize does not promote a colon-first relative reference to a scheme (skipEscape)', (t) => {
130
+ // RFC 3986 section 4.2: a first path segment containing ":" must stay
131
+ // protected by a leading "./", or it is mistaken for a scheme. Collapsing
132
+ // the protecting dot segment must not expose the colon. The default path
133
+ // escapes the colon; with skipEscape the colon is emitted raw and the
134
+ // reference is reparsed as absolute (scheme "a"), changing its meaning.
135
+ const colonRefs = ['./a:b', 'a/../a:b']
136
+ for (const ref of colonRefs) {
137
+ const normalized = fastURI.normalize(ref, { skipEscape: true })
138
+ t.equal(
139
+ fastURI.parse(normalized).scheme,
140
+ undefined,
141
+ `${ref} -> ${normalized} must not introduce a scheme`
142
+ )
143
+ for (const base of ABSOLUTE_BASES) {
144
+ t.equal(
145
+ fastURI.resolve(base, normalized, { skipEscape: true }),
146
+ fastURI.resolve(base, ref, { skipEscape: true }),
147
+ `${base} + ${ref}: normalized ${normalized} must resolve the same`
148
+ )
149
+ }
150
+ }
151
+ t.end()
152
+ })
@@ -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,15 +47,39 @@ 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
57
  const mailto = fastURI.parse('ma%69lto:user@example.org')
53
- t.deepEqual(mailto.to, ['user@example.org'], 'mailto parsing runs after decoding the scheme')
58
+ t.equal(mailto.to, undefined, 'mailto parsing does not run for an encoded scheme')
59
+ t.equal(mailto.error, MALFORMED_SCHEME_ERROR)
60
+ t.end()
61
+ })
62
+
63
+ test('normalization cannot synthesize a scheme from encoded input', (t) => {
64
+ const inputs = [
65
+ '%6Aavascript:alert(1)',
66
+ '%4A%41%56%41%53%43%52%49%50%54:alert(1)',
67
+ '%u006Aavascript:alert(1)',
68
+ 'j%61vascript:alert(1)',
69
+ '%64ata:text/html,<script>alert(1)</script>',
70
+ 'vb%73cript:msgbox(1)'
71
+ ]
72
+
73
+ for (const uri of inputs) {
74
+ const parsed = fastURI.parse(uri)
75
+ t.equal(parsed.error, MALFORMED_SCHEME_ERROR, uri + ' is malformed')
76
+ t.notOk(/^(?:javascript|data|vbscript):/i.test(String(parsed.scheme) + ':'), uri + ' parsed scheme is not decoded')
77
+ t.equal(fastURI.normalize(uri), uri, uri + ' normalizes to itself')
78
+ t.equal(fastURI.equal(uri, uri), false, uri + ' is not comparable')
79
+ t.throws(() => fastURI.resolve('https://example.com/app/', uri), /URI scheme is malformed\./, uri + ' is rejected by resolve')
80
+ }
81
+
82
+ t.equal(fastURI.equal('%68ttp://a/', 'http://a/'), false, 'encoded scheme does not alias a plain scheme')
54
83
  t.end()
55
84
  })
56
85
 
@@ -93,11 +122,21 @@ test('resolve rejects malformed decoded schemes in either input', (t) => {
93
122
  t.end()
94
123
  })
95
124
 
96
- test('serialize validates decoded component schemes', (t) => {
125
+ test('serialize validates component schemes without decoding them', (t) => {
97
126
  t.equal(
98
- fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }),
127
+ fastURI.serialize({ scheme: 'foo+bar', path: 'value' }),
99
128
  'foo+bar:value',
100
- 'valid decoded scheme is serialized'
129
+ 'valid scheme is serialized'
130
+ )
131
+ t.throws(
132
+ () => fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }),
133
+ /URI scheme is malformed\./,
134
+ 'percent-encoded scheme is rejected'
135
+ )
136
+ t.throws(
137
+ () => fastURI.serialize({ scheme: '%6Aavascript', path: 'alert(1)' }),
138
+ /URI scheme is malformed\./,
139
+ 'encoded scheme cannot serialize as javascript:'
101
140
  )
102
141
  t.throws(
103
142
  () => fastURI.serialize({ scheme: '//evil.example', path: '/pwn' }),