fast-uri 4.1.2 → 4.1.4

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,93 @@ 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
+ * Whether the host is a bracketed IP literal (RFC 3986 `IP-literal`).
333
+ * An unterminated `[` is not a literal, so it must still be validated as a
334
+ * reg-name instead of being waved through as an IP.
335
+ *
336
+ * @param {string} host
337
+ * @returns {boolean}
338
+ */
339
+ function isIPLiteral (host) {
340
+ return host[0] === '[' && host[host.length - 1] === ']'
341
+ }
342
+
343
+ /**
344
+ * @param {RegExpMatchArray} matches
345
+ * @returns {boolean}
346
+ */
347
+ function hasMalformedComponentPercentEncoding (matches) {
348
+ // Bracketed IP literals use a raw "%" as the zone separator for historical
349
+ // compatibility. Their parsing is intentionally left to normalizeIPv6.
350
+ const host = matches[4]
351
+ return hasMalformedPercentEncoding(matches[3]) ||
352
+ (host !== undefined && !isIPLiteral(host) && hasMalformedPercentEncoding(host)) ||
353
+ hasMalformedPercentEncoding(matches[6]) ||
354
+ hasMalformedPercentEncoding(matches[7]) ||
355
+ hasMalformedPercentEncoding(matches[8])
356
+ }
357
+
358
+ /**
359
+ * @param {import('./types/index').URIComponent} parsed
360
+ * @param {import('./types/index').Options} options
361
+ * @param {{ domainHost?: boolean, unicodeSupport?: boolean }|undefined} schemeHandler
362
+ * @param {boolean} isIP
363
+ * @returns {boolean} whether host conversion failed
364
+ */
365
+ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
366
+ const bracketedIPLiteral =
367
+ parsed.host &&
368
+ parsed.host[0] === '[' &&
369
+ parsed.host[parsed.host.length - 1] === ']'
370
+
371
+ if (
372
+ !options.unicodeSupport &&
373
+ (!schemeHandler || !schemeHandler.unicodeSupport) &&
374
+ parsed.host &&
375
+ !isIPLiteral(parsed.host) &&
376
+ (options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
377
+ isIP === false &&
378
+ !bracketedIPLiteral &&
379
+ nonSimpleDomain(parsed.host)
380
+ ) {
381
+ try {
382
+ parsed.host = new URL('http://' + parsed.host).hostname
383
+ } catch (e) {
384
+ parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
385
+ return true
386
+ }
387
+ }
388
+
389
+ return false
390
+ }
391
+
240
392
  /**
241
393
  * @param {string} uri
242
394
  * @param {import('./types/index').Options} [opts]
243
- * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean }}
395
+ * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
244
396
  */
245
397
  function parseWithStatus (uri, opts) {
246
398
  const options = Object.assign({}, opts)
@@ -256,6 +408,11 @@ function parseWithStatus (uri, opts) {
256
408
  }
257
409
 
258
410
  let malformedAuthorityOrPort = false
411
+ let malformedPercentEncoding = false
412
+ let malformedSchemeSpecific = false
413
+ let malformedHost = false
414
+ let malformedIPLiteral = false
415
+ let malformedScheme = false
259
416
 
260
417
  let isIP = false
261
418
  if (options.reference === 'suffix') {
@@ -305,7 +462,7 @@ function parseWithStatus (uri, opts) {
305
462
 
306
463
  if (matches) {
307
464
  // store each component
308
- parsed.scheme = matches[1] === undefined ? undefined : matches[1].toLowerCase()
465
+ parsed.scheme = matches[1]
309
466
  parsed.userinfo = matches[3]
310
467
  parsed.host = matches[4]
311
468
  parsed.port = parseInt(matches[5], 10)
@@ -313,6 +470,21 @@ function parseWithStatus (uri, opts) {
313
470
  parsed.query = matches[7]
314
471
  parsed.fragment = matches[8]
315
472
 
473
+ if (parsed.scheme !== undefined) {
474
+ const decodedScheme = unescape(parsed.scheme)
475
+ if (VALID_SCHEME.test(decodedScheme)) {
476
+ parsed.scheme = decodedScheme.toLowerCase()
477
+ } else {
478
+ parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
479
+ malformedScheme = true
480
+ }
481
+ }
482
+
483
+ malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches)
484
+ if (malformedPercentEncoding) {
485
+ parsed.error = parsed.error || 'URI contains malformed percent-encoding.'
486
+ }
487
+
316
488
  // fix port number
317
489
  if (isNaN(parsed.port)) {
318
490
  parsed.port = matches[5]
@@ -327,9 +499,17 @@ function parseWithStatus (uri, opts) {
327
499
  if (parsed.host) {
328
500
  const ipv4result = isIPv4(parsed.host)
329
501
  if (ipv4result === false) {
502
+ const bracketedIPLiteral = isIPLiteral(parsed.host)
503
+ const hasIPLiteralBracket = parsed.host.indexOf('[') !== -1 || parsed.host.indexOf(']') !== -1
330
504
  const ipv6result = normalizeIPv6(parsed.host)
331
- parsed.host = ipv6result.host.toLowerCase()
332
- isIP = ipv6result.isIPV6
505
+ isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true
506
+ malformedIPLiteral = hasIPLiteralBracket && (!bracketedIPLiteral || ipv6result.error === true)
507
+ parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
508
+
509
+ if (malformedIPLiteral) {
510
+ parsed.error = parsed.error || 'URI host is malformed.'
511
+ malformedAuthorityOrPort = true
512
+ }
333
513
  } else {
334
514
  isIP = true
335
515
  }
@@ -352,27 +532,16 @@ function parseWithStatus (uri, opts) {
352
532
  // find scheme handler
353
533
  const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme)
354
534
 
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
535
+ // convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts
536
+ if (!malformedIPLiteral) {
537
+ malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
367
538
  }
368
539
 
369
540
  if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
370
541
  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)
542
+ if (parsed.host !== undefined && !malformedIPLiteral) {
543
+ const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
544
+ parsed.host = reescapeHostDelimiters(host, isIP)
376
545
  }
377
546
  }
378
547
  if (parsed.path) {
@@ -389,11 +558,14 @@ function parseWithStatus (uri, opts) {
389
558
  // perform scheme specific parsing
390
559
  if (schemeHandler && schemeHandler.parse) {
391
560
  schemeHandler.parse(parsed, options)
561
+ if (schemeHandler === SCHEMES.urn && parsed.nid === undefined) {
562
+ malformedSchemeSpecific = true
563
+ }
392
564
  }
393
565
  } else {
394
566
  parsed.error = parsed.error || 'URI can not be parsed.'
395
567
  }
396
- return { parsed, malformedAuthorityOrPort }
568
+ return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
397
569
  }
398
570
 
399
571
  /**
@@ -417,13 +589,17 @@ function normalizeString (uri, opts) {
417
589
  /**
418
590
  * @param {string} uri
419
591
  * @param {import('./types/index').Options} [opts]
420
- * @returns {{ normalized: string, malformedAuthorityOrPort: boolean }}
592
+ * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
421
593
  */
422
594
  function normalizeStringWithStatus (uri, opts) {
423
- const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts)
595
+ const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
424
596
  return {
425
- normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
426
- malformedAuthorityOrPort
597
+ normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
598
+ malformedAuthorityOrPort,
599
+ malformedPercentEncoding,
600
+ malformedSchemeSpecific,
601
+ malformedHost,
602
+ malformedScheme
427
603
  }
428
604
  }
429
605
 
@@ -433,14 +609,18 @@ function normalizeStringWithStatus (uri, opts) {
433
609
  * @returns {string|undefined}
434
610
  */
435
611
  function normalizeComparableURI (uri, opts) {
436
- if (typeof uri === 'string') {
437
- const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts)
438
- return malformedAuthorityOrPort ? undefined : normalized
612
+ if (typeof uri !== 'string' && typeof uri !== 'object') {
613
+ return undefined
439
614
  }
440
615
 
441
- if (typeof uri === 'object') {
442
- return serialize(uri, opts)
616
+ let value
617
+ try {
618
+ value = typeof uri === 'string' ? uri : serialize(uri, opts)
619
+ } catch {
620
+ return undefined
443
621
  }
622
+ const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
623
+ return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
444
624
  }
445
625
 
446
626
  const fastUri = {