fast-uri 3.1.6 → 3.1.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.
package/index.js CHANGED
@@ -324,6 +324,18 @@ function hasMalformedPercentEncoding (component) {
324
324
  return false
325
325
  }
326
326
 
327
+ /**
328
+ * Whether the host is a bracketed IP literal (RFC 3986 `IP-literal`).
329
+ * An unterminated `[` is not a literal, so it must still be validated as a
330
+ * reg-name instead of being waved through as an IP.
331
+ *
332
+ * @param {string} host
333
+ * @returns {boolean}
334
+ */
335
+ function isIPLiteral (host) {
336
+ return host[0] === '[' && host[host.length - 1] === ']'
337
+ }
338
+
327
339
  /**
328
340
  * @param {RegExpMatchArray} matches
329
341
  * @returns {boolean}
@@ -333,7 +345,7 @@ function hasMalformedComponentPercentEncoding (matches) {
333
345
  // compatibility. Their parsing is intentionally left to normalizeIPv6.
334
346
  const host = matches[4]
335
347
  return hasMalformedPercentEncoding(matches[3]) ||
336
- (host !== undefined && !(host[0] === '[' && host[host.length - 1] === ']') && hasMalformedPercentEncoding(host)) ||
348
+ (host !== undefined && !isIPLiteral(host) && hasMalformedPercentEncoding(host)) ||
337
349
  hasMalformedPercentEncoding(matches[6]) ||
338
350
  hasMalformedPercentEncoding(matches[7]) ||
339
351
  hasMalformedPercentEncoding(matches[8])
@@ -351,7 +363,7 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
351
363
  !options.unicodeSupport &&
352
364
  (!schemeHandler || !schemeHandler.unicodeSupport) &&
353
365
  parsed.host &&
354
- parsed.host[0] !== '[' &&
366
+ !isIPLiteral(parsed.host) &&
355
367
  (options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
356
368
  isIP === false &&
357
369
  nonSimpleDomain(parsed.host)
@@ -476,10 +488,11 @@ function parseWithStatus (uri, opts) {
476
488
  if (parsed.host) {
477
489
  const ipv4result = isIPv4(parsed.host)
478
490
  if (ipv4result === false) {
479
- const bracketedIPLiteral = parsed.host[0] === '[' && parsed.host[parsed.host.length - 1] === ']'
491
+ const bracketedIPLiteral = isIPLiteral(parsed.host)
492
+ const hasIPLiteralBracket = parsed.host.indexOf('[') !== -1 || parsed.host.indexOf(']') !== -1
480
493
  const ipv6result = normalizeIPv6(parsed.host)
481
494
  isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true
482
- malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true
495
+ malformedIPLiteral = hasIPLiteralBracket && (!bracketedIPLiteral || ipv6result.error === true)
483
496
  parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
484
497
 
485
498
  if (malformedIPLiteral) {
@@ -509,15 +522,21 @@ function parseWithStatus (uri, opts) {
509
522
  const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme)
510
523
 
511
524
  // convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts
512
- malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
525
+ if (!malformedIPLiteral) {
526
+ malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
527
+ }
513
528
 
514
- if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
515
- if (uri.indexOf('%') !== -1) {
516
- if (parsed.host !== undefined && !malformedIPLiteral) {
517
- const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
518
- parsed.host = reescapeHostDelimiters(host, isIP)
519
- }
529
+ if (uri.indexOf('%') !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
530
+ let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
531
+ if (!isIP) {
532
+ // Fold reg-name case after decoding unreserved octets. The second
533
+ // pass only restores uppercase hex in escapes that remain encoded.
534
+ host = normalizePercentEncoding(host.toLowerCase())
520
535
  }
536
+ parsed.host = reescapeHostDelimiters(host, isIP)
537
+ }
538
+
539
+ if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
521
540
  if (parsed.path) {
522
541
  parsed.path = normalizePathEncoding(parsed.path)
523
542
  }
package/lib/utils.js CHANGED
@@ -6,6 +6,9 @@ const isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\d
6
6
  /** @type {(value: string) => boolean} */
7
7
  const isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u)
8
8
 
9
+ /** @type {(value: string) => boolean} */
10
+ const isPort = RegExp.prototype.test.bind(/^\d*$/u)
11
+
9
12
  /** @type {(value: string) => boolean} */
10
13
  const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
11
14
 
@@ -713,8 +716,12 @@ function recomposeAuthority (component) {
713
716
  }
714
717
 
715
718
  if (typeof component.port === 'number' || typeof component.port === 'string') {
719
+ const port = String(component.port)
720
+ if (!isPort(port)) {
721
+ throw new TypeError('URI port is malformed.')
722
+ }
716
723
  uriTokens.push(':')
717
- uriTokens.push(String(component.port))
724
+ uriTokens.push(port)
718
725
  }
719
726
 
720
727
  return uriTokens.length ? uriTokens.join('') : undefined
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": "3.1.6",
4
+ "version": "3.1.8",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -24,6 +24,64 @@ test('userinfo serialization cannot terminate the authority', (t) => {
24
24
  t.end()
25
25
  })
26
26
 
27
+ test('port serialization rejects non-digit values', (t) => {
28
+ const malformedPorts = [
29
+ '@127.0.0.1:8124',
30
+ '8080@evil.example',
31
+ '8080/path',
32
+ '8080?query',
33
+ '8080#fragment',
34
+ '8080:9000',
35
+ '-1',
36
+ '1.5',
37
+ 1.5,
38
+ NaN,
39
+ Infinity,
40
+ '\u0661'
41
+ ]
42
+
43
+ for (const port of malformedPorts) {
44
+ t.throws(
45
+ () => fastURI.serialize({ scheme: 'http', host: 'trusted.example', port, path: '/app' }),
46
+ /URI port is malformed\./,
47
+ String(port)
48
+ )
49
+ }
50
+
51
+ t.throws(
52
+ () => fastURI.normalize({ scheme: 'http', host: 'trusted.example', port: '@evil.example' }),
53
+ /URI port is malformed\./,
54
+ 'object normalization rejects a malformed port'
55
+ )
56
+ t.equal(
57
+ fastURI.equal(
58
+ { scheme: 'http', host: 'trusted.example', port: '@evil.example' },
59
+ { scheme: 'http', host: 'trusted.example', port: '@evil.example' }
60
+ ),
61
+ false,
62
+ 'object equality fails closed for a malformed port'
63
+ )
64
+ t.end()
65
+ })
66
+
67
+ test('port serialization preserves RFC 3986 digit values', (t) => {
68
+ const validPorts = [
69
+ [8080, '8080'],
70
+ ['8080', '8080'],
71
+ ['00080', '00080'],
72
+ ['', '']
73
+ ]
74
+
75
+ for (const [port, expected] of validPorts) {
76
+ t.equal(
77
+ fastURI.serialize({ scheme: 'uri', host: 'example.test', port }),
78
+ `uri://example.test:${expected}`,
79
+ JSON.stringify(port)
80
+ )
81
+ }
82
+ t.end()
83
+ })
84
+
27
85
  test('query and fragment serialization cannot inject component delimiters', (t) => {
28
86
  const uri = fastURI.serialize({
29
87
  scheme: 'x',
@@ -88,3 +88,37 @@ test('valid IPv6, IPvFuture, embedded IPv4, and zone forms normalize safely', (t
88
88
  }
89
89
  t.end()
90
90
  })
91
+
92
+ test('hosts with unbalanced or misplaced IP-literal brackets are rejected', (t) => {
93
+ const malformed = [
94
+ 'http://[fe80',
95
+ 'http://[',
96
+ 'http://[not-an-ip',
97
+ 'http://[\u65e5\u672c',
98
+ 'http://user@[@127.0.0.1:8123/admin',
99
+ 'http://user@]127.0.0.1:8123/admin',
100
+ 'http://user@prefix[@127.0.0.1:8123/admin',
101
+ 'http://user@prefix]@127.0.0.1:8123/admin'
102
+ ]
103
+ const modes = [
104
+ ['default', undefined],
105
+ ['Unicode', { unicodeSupport: true }]
106
+ ]
107
+
108
+ for (const [mode, options] of modes) {
109
+ for (const uri of malformed) {
110
+ const parsed = fastURI.parse(uri, options)
111
+ const message = `${mode} mode rejects ${uri}`
112
+
113
+ t.equal(parsed.error, HOST_ERROR, `parse ${message}`)
114
+ t.equal(fastURI.normalize(uri, options), uri, `normalize preserves ${uri} in ${mode} mode`)
115
+ t.equal(fastURI.equal(uri, uri, options), false, `equal ${message}`)
116
+ t.throws(
117
+ () => fastURI.resolve('http://example.com/', uri, options),
118
+ /URI host is malformed\./,
119
+ `resolve ${message}`
120
+ )
121
+ }
122
+ }
123
+ t.end()
124
+ })
@@ -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