fast-uri 4.1.2 → 4.1.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.
@@ -147,7 +147,7 @@ jobs:
147
147
  permissions:
148
148
  contents: write
149
149
  pull-requests: write
150
- uses: fastify/workflows/.github/workflows/plugins-ci.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0
150
+ uses: fastify/workflows/.github/workflows/plugins-ci.yml@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0
151
151
  with:
152
152
  license-check: true
153
153
  lint: true
@@ -16,4 +16,4 @@ jobs:
16
16
  permissions:
17
17
  issues: write
18
18
  pull-requests: write
19
- uses: fastify/workflows/.github/workflows/lock-threads.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0
19
+ uses: fastify/workflows/.github/workflows/lock-threads.yml@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0
package/SECURITY.md CHANGED
@@ -11,10 +11,15 @@ official Fastify project package. It supplements the Fastify project's
11
11
  [WHATWG URL Standard](https://url.spec.whatwg.org/).
12
12
 
13
13
  Differences between `fast-uri` and WHATWG URL implementations are expected.
14
- Reports based on comparing or mixing parsers that follow these different
14
+ Reports based solely on comparing or mixing parsers that follow these different
15
15
  standards are out of scope. Applications must use the same parsing and
16
16
  normalization rules for both security decisions and subsequent URI use.
17
17
 
18
+ Reports are assessed based on reproducibility, documented supported usage, and
19
+ concrete security impact—not merely on whether they mention WHATWG URL
20
+ behavior. Reports demonstrating a parsing or normalization flaw in a supported
21
+ scheme remain eligible for evaluation.
22
+
18
23
  ## Threat model
19
24
 
20
25
  `fast-uri`'s threat model extends the
@@ -10,6 +10,21 @@ const ipv6 = '//[2001:db8::7]'
10
10
  const urn = 'urn:foo:a123,456'
11
11
  const urnuuid = 'urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6'
12
12
 
13
+ const mailtoSimple = 'mailto:chris@example.com'
14
+ const mailtoWithSubject = 'mailto:infobot@example.com?subject=current-issue'
15
+ const mailtoWithBody = 'mailto:infobot@example.com?body=send%20current-issue%0D%0Asend%20index'
16
+ const mailtoWithHeaders = 'mailto:list@example.org?In-Reply-To=%3C3469A91.D10AF4C@example.com%3E'
17
+ const mailtoMultiple = 'mailto:joe@example.com,alice@example.org?subject=Test&body=NATTO'
18
+ const mailtoEncoded = 'mailto:%22oh%5C%5Cno%22@example.org'
19
+
20
+ const mailtoComponent = {
21
+ scheme: 'mailto',
22
+ to: ['chris@example.com'],
23
+ subject: 'current-issue',
24
+ body: 'send current-issue\r\nsend index',
25
+ headers: { 'In-Reply-To': '<3469A91.D10AF4C@example.com>' }
26
+ }
27
+
13
28
  const urnuuidComponent = {
14
29
  scheme: 'urn',
15
30
  nid: 'uuid',
@@ -146,6 +161,62 @@ benchUriJs.add('urijs: equal', function () {
146
161
  uriJsEqual('example://a/b/c/%7Bfoo%7D', 'eXAMPLE://a/./b/../b/%63/%7bfoo%7d')
147
162
  })
148
163
 
164
+ benchFastUri.add('fast-uri: parse mailto simple', function () {
165
+ fastUriParse(mailtoSimple)
166
+ })
167
+ benchUriJs.add('urijs: parse mailto simple', function () {
168
+ uriJsParse(mailtoSimple)
169
+ })
170
+
171
+ benchFastUri.add('fast-uri: parse mailto subject', function () {
172
+ fastUriParse(mailtoWithSubject)
173
+ })
174
+ benchUriJs.add('urijs: parse mailto subject', function () {
175
+ uriJsParse(mailtoWithSubject)
176
+ })
177
+
178
+ benchFastUri.add('fast-uri: parse mailto body CRLF', function () {
179
+ fastUriParse(mailtoWithBody)
180
+ })
181
+ benchUriJs.add('urijs: parse mailto body CRLF', function () {
182
+ uriJsParse(mailtoWithBody)
183
+ })
184
+
185
+ benchFastUri.add('fast-uri: parse mailto headers', function () {
186
+ fastUriParse(mailtoWithHeaders)
187
+ })
188
+ benchUriJs.add('urijs: parse mailto headers', function () {
189
+ uriJsParse(mailtoWithHeaders)
190
+ })
191
+
192
+ benchFastUri.add('fast-uri: parse mailto multi recipient', function () {
193
+ fastUriParse(mailtoMultiple)
194
+ })
195
+ benchUriJs.add('urijs: parse mailto multi recipient', function () {
196
+ uriJsParse(mailtoMultiple)
197
+ })
198
+
199
+ benchFastUri.add('fast-uri: parse mailto encoded local', function () {
200
+ fastUriParse(mailtoEncoded)
201
+ })
202
+ benchUriJs.add('urijs: parse mailto encoded local', function () {
203
+ uriJsParse(mailtoEncoded)
204
+ })
205
+
206
+ benchFastUri.add('fast-uri: serialize mailto', function () {
207
+ fastUriSerialize(mailtoComponent)
208
+ })
209
+ benchUriJs.add('urijs: serialize mailto', function () {
210
+ uriJsSerialize(mailtoComponent)
211
+ })
212
+
213
+ benchFastUri.add('fast-uri: serialize+parse mailto round-trip', function () {
214
+ fastUriSerialize(fastUriParse(mailtoWithBody))
215
+ })
216
+ benchUriJs.add('urijs: serialize+parse mailto round-trip', function () {
217
+ uriJsSerialize(uriJsParse(mailtoWithBody))
218
+ })
219
+
149
220
  await benchFastUri.run()
150
221
  console.log(benchFastUri.name)
151
222
  console.table(benchFastUri.table())
package/index.js CHANGED
@@ -1,8 +1,23 @@
1
1
  'use strict'
2
2
 
3
- const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, normalizeQueryFragmentEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
3
+ const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
4
4
  const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
5
5
 
6
+ const VALID_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/u
7
+ const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.'
8
+
9
+ /**
10
+ * @param {string} scheme
11
+ * @returns {string}
12
+ */
13
+ function decodeValidScheme (scheme) {
14
+ const decodedScheme = unescape(String(scheme))
15
+ if (!VALID_SCHEME.test(decodedScheme)) {
16
+ throw new TypeError(MALFORMED_SCHEME_ERROR)
17
+ }
18
+ return decodedScheme
19
+ }
20
+
6
21
  /**
7
22
  * @template {import('./types/index').URIComponent|string} T
8
23
  * @param {T} uri
@@ -26,12 +41,50 @@ function normalize (uri, options) {
26
41
  */
27
42
  function resolve (baseURI, relativeURI, options) {
28
43
  const schemelessOptions = options ? Object.assign({ scheme: 'null' }, options) : { scheme: 'null' }
29
- const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions)
30
- const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions)
31
- if (baseMalformed || relativeMalformed) {
44
+ const {
45
+ parsed: baseParsed,
46
+ malformedAuthorityOrPort: baseMalformed,
47
+ malformedPercentEncoding: baseMalformedPercentEncoding,
48
+ malformedSchemeSpecific: baseMalformedSchemeSpecific,
49
+ malformedHost: baseMalformedHost,
50
+ malformedScheme: baseMalformedScheme
51
+ } = parseWithStatus(baseURI, schemelessOptions)
52
+ const {
53
+ parsed: relativeParsed,
54
+ malformedAuthorityOrPort: relativeMalformed,
55
+ malformedPercentEncoding: relativeMalformedPercentEncoding,
56
+ malformedSchemeSpecific: relativeMalformedSchemeSpecific,
57
+ malformedHost: relativeMalformedHost,
58
+ malformedScheme: relativeMalformedScheme
59
+ } = parseWithStatus(relativeURI, schemelessOptions)
60
+ if (
61
+ baseMalformed ||
62
+ relativeMalformed ||
63
+ baseMalformedPercentEncoding ||
64
+ relativeMalformedPercentEncoding ||
65
+ baseMalformedSchemeSpecific ||
66
+ relativeMalformedSchemeSpecific ||
67
+ baseMalformedHost ||
68
+ relativeMalformedHost ||
69
+ baseMalformedScheme ||
70
+ relativeMalformedScheme
71
+ ) {
32
72
  throw new Error(baseParsed.error || relativeParsed.error || 'URI is malformed.')
33
73
  }
34
74
  const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true)
75
+ const resolvedSchemeHandler = getSchemeHandler((options && options.scheme) || resolved.scheme)
76
+ const resolvedHost = resolved.host
77
+ const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
78
+ (isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6)
79
+ canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
80
+ // Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname
81
+ // parser can reject them even though fast-uri preserves them safely as RFC
82
+ // 3986 data. A raw non-ASCII host must still fail closed if conversion fails.
83
+ const encodedASCIIHost = resolvedHost && resolvedHost.indexOf('%') !== -1 &&
84
+ !/\P{ASCII}/u.test(resolvedHost)
85
+ if (resolved.error && !encodedASCIIHost) {
86
+ throw new Error(resolved.error)
87
+ }
35
88
  schemelessOptions.skipEscape = true
36
89
  return serialize(resolved, schemelessOptions)
37
90
  }
@@ -114,7 +167,7 @@ function equal (uriA, uriB, options) {
114
167
  const normalizedA = normalizeComparableURI(uriA, options)
115
168
  const normalizedB = normalizeComparableURI(uriB, options)
116
169
 
117
- return normalizedA !== undefined && normalizedB !== undefined && normalizedA.toLowerCase() === normalizedB.toLowerCase()
170
+ return normalizedA !== undefined && normalizedB !== undefined && normalizedA === normalizedB
118
171
  }
119
172
 
120
173
  /**
@@ -137,30 +190,39 @@ function serialize (cmpts, opts) {
137
190
  reference: cmpts.reference,
138
191
  resourceName: cmpts.resourceName,
139
192
  secure: cmpts.secure,
193
+ to: cmpts.to,
194
+ subject: cmpts.subject,
195
+ body: cmpts.body,
196
+ headers: cmpts.headers,
140
197
  error: ''
141
198
  }
142
199
  const options = Object.assign({}, opts)
143
200
  const uriTokens = []
144
201
 
202
+ if (component.scheme) {
203
+ component.scheme = decodeValidScheme(component.scheme)
204
+ }
205
+
145
206
  // find scheme handler
146
207
  const schemeHandler = getSchemeHandler(options.scheme || component.scheme)
147
208
 
148
209
  // perform scheme specific serialization
149
210
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options)
150
211
 
212
+ const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
213
+ const pathNoScheme = !options.skipEscape && component.scheme === undefined && !hasAuthority
214
+
151
215
  if (component.path !== undefined) {
152
216
  if (!options.skipEscape) {
153
- component.path = escapePreservingEscapes(component.path)
154
-
155
- if (component.scheme !== undefined) {
156
- component.path = component.path.split('%3A').join(':')
157
- }
217
+ component.path = serializePathEncoding(component.path, pathNoScheme)
158
218
  } else {
159
219
  component.path = normalizePercentEncoding(component.path)
160
220
  }
161
221
  }
162
222
 
163
223
  if (options.reference !== 'suffix' && component.scheme) {
224
+ // Scheme handlers may replace the scheme during serialization.
225
+ component.scheme = decodeValidScheme(component.scheme)
164
226
  uriTokens.push(component.scheme, ':')
165
227
  }
166
228
 
@@ -183,6 +245,13 @@ function serialize (cmpts, opts) {
183
245
  s = removeDotSegments(s)
184
246
  }
185
247
 
248
+ // Dot-segment removal can expose a colon that was not originally in the
249
+ // first segment (for example, "./a:b"). Reapply path-noscheme encoding so
250
+ // the serialized relative reference cannot be reparsed as a URI scheme.
251
+ if (pathNoScheme) {
252
+ s = serializePathEncoding(s, true)
253
+ }
254
+
186
255
  if (
187
256
  authority === undefined &&
188
257
  s[0] === '/' &&
@@ -196,11 +265,11 @@ function serialize (cmpts, opts) {
196
265
  }
197
266
 
198
267
  if (component.query !== undefined) {
199
- uriTokens.push('?', component.query)
268
+ uriTokens.push('?', encodeQuery(component.query))
200
269
  }
201
270
 
202
271
  if (component.fragment !== undefined) {
203
- uriTokens.push('#', component.fragment)
272
+ uriTokens.push('#', encodeFragment(component.fragment))
204
273
  }
205
274
  return uriTokens.join('')
206
275
  }
@@ -237,10 +306,74 @@ function getParseError (parsed, matches) {
237
306
  return undefined
238
307
  }
239
308
 
309
+ /**
310
+ * Checks percent syntax without decoding the represented octets. RFC 3986
311
+ * percent-encoding is byte-oriented, so sequences such as `%FF` are valid even
312
+ * though they are not independently valid UTF-8.
313
+ *
314
+ * @param {string|undefined} component
315
+ * @returns {boolean}
316
+ */
317
+ function hasMalformedPercentEncoding (component) {
318
+ if (component === undefined) return false
319
+
320
+ let percent = component.indexOf('%')
321
+ while (percent !== -1) {
322
+ if (percent + 2 >= component.length || !/^[\da-f]{2}$/iu.test(component.slice(percent + 1, percent + 3))) {
323
+ return true
324
+ }
325
+ percent = component.indexOf('%', percent + 3)
326
+ }
327
+
328
+ return false
329
+ }
330
+
331
+ /**
332
+ * @param {RegExpMatchArray} matches
333
+ * @returns {boolean}
334
+ */
335
+ function hasMalformedComponentPercentEncoding (matches) {
336
+ // Bracketed IP literals use a raw "%" as the zone separator for historical
337
+ // compatibility. Their parsing is intentionally left to normalizeIPv6.
338
+ const host = matches[4]
339
+ return hasMalformedPercentEncoding(matches[3]) ||
340
+ (host !== undefined && !(host[0] === '[' && host[host.length - 1] === ']') && hasMalformedPercentEncoding(host)) ||
341
+ hasMalformedPercentEncoding(matches[6]) ||
342
+ hasMalformedPercentEncoding(matches[7]) ||
343
+ hasMalformedPercentEncoding(matches[8])
344
+ }
345
+
346
+ /**
347
+ * @param {import('./types/index').URIComponent} parsed
348
+ * @param {import('./types/index').Options} options
349
+ * @param {{ domainHost?: boolean, unicodeSupport?: boolean }|undefined} schemeHandler
350
+ * @param {boolean} isIP
351
+ * @returns {boolean} whether host conversion failed
352
+ */
353
+ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
354
+ if (
355
+ !options.unicodeSupport &&
356
+ (!schemeHandler || !schemeHandler.unicodeSupport) &&
357
+ parsed.host &&
358
+ parsed.host[0] !== '[' &&
359
+ (options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
360
+ isIP === false &&
361
+ nonSimpleDomain(parsed.host)
362
+ ) {
363
+ try {
364
+ parsed.host = new URL('http://' + parsed.host).hostname
365
+ } catch (e) {
366
+ parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
367
+ return true
368
+ }
369
+ }
370
+ return false
371
+ }
372
+
240
373
  /**
241
374
  * @param {string} uri
242
375
  * @param {import('./types/index').Options} [opts]
243
- * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean }}
376
+ * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
244
377
  */
245
378
  function parseWithStatus (uri, opts) {
246
379
  const options = Object.assign({}, opts)
@@ -256,6 +389,11 @@ function parseWithStatus (uri, opts) {
256
389
  }
257
390
 
258
391
  let malformedAuthorityOrPort = false
392
+ let malformedPercentEncoding = false
393
+ let malformedSchemeSpecific = false
394
+ let malformedHost = false
395
+ let malformedIPLiteral = false
396
+ let malformedScheme = false
259
397
 
260
398
  let isIP = false
261
399
  if (options.reference === 'suffix') {
@@ -305,7 +443,7 @@ function parseWithStatus (uri, opts) {
305
443
 
306
444
  if (matches) {
307
445
  // store each component
308
- parsed.scheme = matches[1] === undefined ? undefined : matches[1].toLowerCase()
446
+ parsed.scheme = matches[1]
309
447
  parsed.userinfo = matches[3]
310
448
  parsed.host = matches[4]
311
449
  parsed.port = parseInt(matches[5], 10)
@@ -313,6 +451,21 @@ function parseWithStatus (uri, opts) {
313
451
  parsed.query = matches[7]
314
452
  parsed.fragment = matches[8]
315
453
 
454
+ if (parsed.scheme !== undefined) {
455
+ const decodedScheme = unescape(parsed.scheme)
456
+ if (VALID_SCHEME.test(decodedScheme)) {
457
+ parsed.scheme = decodedScheme.toLowerCase()
458
+ } else {
459
+ parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
460
+ malformedScheme = true
461
+ }
462
+ }
463
+
464
+ malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches)
465
+ if (malformedPercentEncoding) {
466
+ parsed.error = parsed.error || 'URI contains malformed percent-encoding.'
467
+ }
468
+
316
469
  // fix port number
317
470
  if (isNaN(parsed.port)) {
318
471
  parsed.port = matches[5]
@@ -327,9 +480,16 @@ function parseWithStatus (uri, opts) {
327
480
  if (parsed.host) {
328
481
  const ipv4result = isIPv4(parsed.host)
329
482
  if (ipv4result === false) {
483
+ const bracketedIPLiteral = parsed.host[0] === '[' && parsed.host[parsed.host.length - 1] === ']'
330
484
  const ipv6result = normalizeIPv6(parsed.host)
331
- parsed.host = ipv6result.host.toLowerCase()
332
- isIP = ipv6result.isIPV6
485
+ isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true
486
+ malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true
487
+ parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
488
+
489
+ if (malformedIPLiteral) {
490
+ parsed.error = parsed.error || 'URI host is malformed.'
491
+ malformedAuthorityOrPort = true
492
+ }
333
493
  } else {
334
494
  isIP = true
335
495
  }
@@ -352,27 +512,14 @@ function parseWithStatus (uri, opts) {
352
512
  // find scheme handler
353
513
  const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme)
354
514
 
355
- // check if scheme can't handle IRIs
356
- if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
357
- // if host component is a domain name
358
- if (parsed.host && (options.domainHost || (schemeHandler && schemeHandler.domainHost)) && isIP === false && nonSimpleDomain(parsed.host)) {
359
- // convert Unicode IDN -> ASCII IDN
360
- try {
361
- parsed.host = new URL('http://' + parsed.host).hostname
362
- } catch (e) {
363
- parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
364
- }
365
- }
366
- // convert IRI -> URI
367
- }
515
+ // convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts
516
+ malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
368
517
 
369
518
  if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
370
519
  if (uri.indexOf('%') !== -1) {
371
- if (parsed.scheme !== undefined) {
372
- parsed.scheme = unescape(parsed.scheme)
373
- }
374
- if (parsed.host !== undefined) {
375
- parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP)
520
+ if (parsed.host !== undefined && !malformedIPLiteral) {
521
+ const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
522
+ parsed.host = reescapeHostDelimiters(host, isIP)
376
523
  }
377
524
  }
378
525
  if (parsed.path) {
@@ -389,11 +536,14 @@ function parseWithStatus (uri, opts) {
389
536
  // perform scheme specific parsing
390
537
  if (schemeHandler && schemeHandler.parse) {
391
538
  schemeHandler.parse(parsed, options)
539
+ if (schemeHandler === SCHEMES.urn && parsed.nid === undefined) {
540
+ malformedSchemeSpecific = true
541
+ }
392
542
  }
393
543
  } else {
394
544
  parsed.error = parsed.error || 'URI can not be parsed.'
395
545
  }
396
- return { parsed, malformedAuthorityOrPort }
546
+ return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
397
547
  }
398
548
 
399
549
  /**
@@ -417,13 +567,17 @@ function normalizeString (uri, opts) {
417
567
  /**
418
568
  * @param {string} uri
419
569
  * @param {import('./types/index').Options} [opts]
420
- * @returns {{ normalized: string, malformedAuthorityOrPort: boolean }}
570
+ * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
421
571
  */
422
572
  function normalizeStringWithStatus (uri, opts) {
423
- const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts)
573
+ const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
424
574
  return {
425
- normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
426
- malformedAuthorityOrPort
575
+ normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
576
+ malformedAuthorityOrPort,
577
+ malformedPercentEncoding,
578
+ malformedSchemeSpecific,
579
+ malformedHost,
580
+ malformedScheme
427
581
  }
428
582
  }
429
583
 
@@ -433,14 +587,18 @@ function normalizeStringWithStatus (uri, opts) {
433
587
  * @returns {string|undefined}
434
588
  */
435
589
  function normalizeComparableURI (uri, opts) {
436
- if (typeof uri === 'string') {
437
- const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts)
438
- return malformedAuthorityOrPort ? undefined : normalized
590
+ if (typeof uri !== 'string' && typeof uri !== 'object') {
591
+ return undefined
439
592
  }
440
593
 
441
- if (typeof uri === 'object') {
442
- return serialize(uri, opts)
594
+ let value
595
+ try {
596
+ value = typeof uri === 'string' ? uri : serialize(uri, opts)
597
+ } catch {
598
+ return undefined
443
599
  }
600
+ const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
601
+ return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
444
602
  }
445
603
 
446
604
  const fastUri = {