fast-uri 3.1.4 → 3.1.6

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
@@ -1,8 +1,23 @@
1
1
  'use strict'
2
2
 
3
- const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, 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,7 +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 resolved = resolveComponent(parse(baseURI, schemelessOptions), parse(relativeURI, schemelessOptions), schemelessOptions, true)
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
+ ) {
72
+ throw new Error(baseParsed.error || relativeParsed.error || 'URI is malformed.')
73
+ }
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
+ }
30
88
  schemelessOptions.skipEscape = true
31
89
  return serialize(resolved, schemelessOptions)
32
90
  }
@@ -109,7 +167,7 @@ function equal (uriA, uriB, options) {
109
167
  const normalizedA = normalizeComparableURI(uriA, options)
110
168
  const normalizedB = normalizeComparableURI(uriB, options)
111
169
 
112
- return normalizedA !== undefined && normalizedB !== undefined && normalizedA.toLowerCase() === normalizedB.toLowerCase()
170
+ return normalizedA !== undefined && normalizedB !== undefined && normalizedA === normalizedB
113
171
  }
114
172
 
115
173
  /**
@@ -137,25 +195,30 @@ function serialize (cmpts, opts) {
137
195
  const options = Object.assign({}, opts)
138
196
  const uriTokens = []
139
197
 
198
+ if (component.scheme) {
199
+ component.scheme = decodeValidScheme(component.scheme)
200
+ }
201
+
140
202
  // find scheme handler
141
203
  const schemeHandler = getSchemeHandler(options.scheme || component.scheme)
142
204
 
143
205
  // perform scheme specific serialization
144
206
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options)
145
207
 
208
+ const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
209
+ const pathNoScheme = !options.skipEscape && component.scheme === undefined && !hasAuthority
210
+
146
211
  if (component.path !== undefined) {
147
212
  if (!options.skipEscape) {
148
- component.path = escapePreservingEscapes(component.path)
149
-
150
- if (component.scheme !== undefined) {
151
- component.path = component.path.split('%3A').join(':')
152
- }
213
+ component.path = serializePathEncoding(component.path, pathNoScheme)
153
214
  } else {
154
215
  component.path = normalizePercentEncoding(component.path)
155
216
  }
156
217
  }
157
218
 
158
219
  if (options.reference !== 'suffix' && component.scheme) {
220
+ // Scheme handlers may replace the scheme during serialization.
221
+ component.scheme = decodeValidScheme(component.scheme)
159
222
  uriTokens.push(component.scheme, ':')
160
223
  }
161
224
 
@@ -178,6 +241,13 @@ function serialize (cmpts, opts) {
178
241
  s = removeDotSegments(s)
179
242
  }
180
243
 
244
+ // Dot-segment removal can expose a colon that was not originally in the
245
+ // first segment (for example, "./a:b"). Reapply path-noscheme encoding so
246
+ // the serialized relative reference cannot be reparsed as a URI scheme.
247
+ if (pathNoScheme) {
248
+ s = serializePathEncoding(s, true)
249
+ }
250
+
181
251
  if (
182
252
  authority === undefined &&
183
253
  s[0] === '/' &&
@@ -191,11 +261,11 @@ function serialize (cmpts, opts) {
191
261
  }
192
262
 
193
263
  if (component.query !== undefined) {
194
- uriTokens.push('?', component.query)
264
+ uriTokens.push('?', encodeQuery(component.query))
195
265
  }
196
266
 
197
267
  if (component.fragment !== undefined) {
198
- uriTokens.push('#', component.fragment)
268
+ uriTokens.push('#', encodeFragment(component.fragment))
199
269
  }
200
270
  return uriTokens.join('')
201
271
  }
@@ -206,6 +276,15 @@ const URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:
206
276
  // with or without a scheme prefix, for the literal-backslash rejection below.
207
277
  const AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/
208
278
 
279
+ // Captures the leading authority-introducer region after an optional scheme: a
280
+ // run of forward slashes, backslashes, and the characters the WHATWG URL parser
281
+ // removes before parsing (TAB U+0009, LF U+000A, CR U+000D). A valid introducer
282
+ // is exactly "//". Node treats "\" as "/" on special schemes and strips those
283
+ // characters first, so forms like "\\", "/\", "\/", "/<TAB>/", or a leading
284
+ // "<TAB>//" reach an authority in Node while fast-uri's URI_PARSE folds them into
285
+ // the path group (host confusion / SSRF / redirect bypass).
286
+ const AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/
287
+
209
288
  /**
210
289
  * @param {import('./types/index').URIComponent} parsed
211
290
  * @param {RegExpMatchArray} matches
@@ -223,10 +302,74 @@ function getParseError (parsed, matches) {
223
302
  return undefined
224
303
  }
225
304
 
305
+ /**
306
+ * Checks percent syntax without decoding the represented octets. RFC 3986
307
+ * percent-encoding is byte-oriented, so sequences such as `%FF` are valid even
308
+ * though they are not independently valid UTF-8.
309
+ *
310
+ * @param {string|undefined} component
311
+ * @returns {boolean}
312
+ */
313
+ function hasMalformedPercentEncoding (component) {
314
+ if (component === undefined) return false
315
+
316
+ let percent = component.indexOf('%')
317
+ while (percent !== -1) {
318
+ if (percent + 2 >= component.length || !/^[\da-f]{2}$/iu.test(component.slice(percent + 1, percent + 3))) {
319
+ return true
320
+ }
321
+ percent = component.indexOf('%', percent + 3)
322
+ }
323
+
324
+ return false
325
+ }
326
+
327
+ /**
328
+ * @param {RegExpMatchArray} matches
329
+ * @returns {boolean}
330
+ */
331
+ function hasMalformedComponentPercentEncoding (matches) {
332
+ // Bracketed IP literals use a raw "%" as the zone separator for historical
333
+ // compatibility. Their parsing is intentionally left to normalizeIPv6.
334
+ const host = matches[4]
335
+ return hasMalformedPercentEncoding(matches[3]) ||
336
+ (host !== undefined && !(host[0] === '[' && host[host.length - 1] === ']') && hasMalformedPercentEncoding(host)) ||
337
+ hasMalformedPercentEncoding(matches[6]) ||
338
+ hasMalformedPercentEncoding(matches[7]) ||
339
+ hasMalformedPercentEncoding(matches[8])
340
+ }
341
+
342
+ /**
343
+ * @param {import('./types/index').URIComponent} parsed
344
+ * @param {import('./types/index').Options} options
345
+ * @param {{ domainHost?: boolean, unicodeSupport?: boolean }|undefined} schemeHandler
346
+ * @param {boolean} isIP
347
+ * @returns {boolean} whether host conversion failed
348
+ */
349
+ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
350
+ if (
351
+ !options.unicodeSupport &&
352
+ (!schemeHandler || !schemeHandler.unicodeSupport) &&
353
+ parsed.host &&
354
+ parsed.host[0] !== '[' &&
355
+ (options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
356
+ isIP === false &&
357
+ nonSimpleDomain(parsed.host)
358
+ ) {
359
+ try {
360
+ parsed.host = new URL('http://' + parsed.host).hostname
361
+ } catch (e) {
362
+ parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
363
+ return true
364
+ }
365
+ }
366
+ return false
367
+ }
368
+
226
369
  /**
227
370
  * @param {string} uri
228
371
  * @param {import('./types/index').Options} [opts]
229
- * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean }}
372
+ * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
230
373
  */
231
374
  function parseWithStatus (uri, opts) {
232
375
  const options = Object.assign({}, opts)
@@ -242,6 +385,11 @@ function parseWithStatus (uri, opts) {
242
385
  }
243
386
 
244
387
  let malformedAuthorityOrPort = false
388
+ let malformedPercentEncoding = false
389
+ let malformedSchemeSpecific = false
390
+ let malformedHost = false
391
+ let malformedIPLiteral = false
392
+ let malformedScheme = false
245
393
 
246
394
  let isIP = false
247
395
  if (options.reference === 'suffix') {
@@ -265,6 +413,28 @@ function parseWithStatus (uri, opts) {
265
413
  malformedAuthorityOrPort = true
266
414
  }
267
415
 
416
+ // Reject a malformed or whitespace-smuggled authority introducer. fast-uri
417
+ // only recognizes a literal "//"; anything else in the leading separator run
418
+ // (a backslash, or a "//" that appears only after removing the TAB/LF/CR that
419
+ // Node strips) means the authority fast-uri parses differs from the one Node's
420
+ // URL resolves. Reject rather than rewrite, mirroring the literal-backslash
421
+ // guard above. Percent-encoded forms (%5C, %09) are untouched, valid data.
422
+ const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION)
423
+ if (introducerMatch !== null) {
424
+ const region = introducerMatch[1]
425
+ const normalizedRegion = region.replace(/[\t\n\r]/g, '')
426
+ // Two or more leading separators introduce an authority.
427
+ if (normalizedRegion.length >= 2) {
428
+ if (normalizedRegion.slice(0, 2) !== '//') {
429
+ parsed.error = parsed.error || 'URI authority must not contain a literal backslash.'
430
+ malformedAuthorityOrPort = true
431
+ } else if (region.length !== normalizedRegion.length) {
432
+ parsed.error = parsed.error || 'URI authority introducer must not contain whitespace.'
433
+ malformedAuthorityOrPort = true
434
+ }
435
+ }
436
+ }
437
+
268
438
  const matches = uri.match(URI_PARSE)
269
439
 
270
440
  if (matches) {
@@ -277,6 +447,21 @@ function parseWithStatus (uri, opts) {
277
447
  parsed.query = matches[7]
278
448
  parsed.fragment = matches[8]
279
449
 
450
+ if (parsed.scheme !== undefined) {
451
+ const decodedScheme = unescape(parsed.scheme)
452
+ if (VALID_SCHEME.test(decodedScheme)) {
453
+ parsed.scheme = decodedScheme.toLowerCase()
454
+ } else {
455
+ parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
456
+ malformedScheme = true
457
+ }
458
+ }
459
+
460
+ malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches)
461
+ if (malformedPercentEncoding) {
462
+ parsed.error = parsed.error || 'URI contains malformed percent-encoding.'
463
+ }
464
+
280
465
  // fix port number
281
466
  if (isNaN(parsed.port)) {
282
467
  parsed.port = matches[5]
@@ -291,9 +476,16 @@ function parseWithStatus (uri, opts) {
291
476
  if (parsed.host) {
292
477
  const ipv4result = isIPv4(parsed.host)
293
478
  if (ipv4result === false) {
479
+ const bracketedIPLiteral = parsed.host[0] === '[' && parsed.host[parsed.host.length - 1] === ']'
294
480
  const ipv6result = normalizeIPv6(parsed.host)
295
- parsed.host = ipv6result.host.toLowerCase()
296
- isIP = ipv6result.isIPV6
481
+ isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true
482
+ malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true
483
+ parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
484
+
485
+ if (malformedIPLiteral) {
486
+ parsed.error = parsed.error || 'URI host is malformed.'
487
+ malformedAuthorityOrPort = true
488
+ }
297
489
  } else {
298
490
  isIP = true
299
491
  }
@@ -316,49 +508,38 @@ function parseWithStatus (uri, opts) {
316
508
  // find scheme handler
317
509
  const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme)
318
510
 
319
- // check if scheme can't handle IRIs
320
- if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
321
- // if host component is a domain name
322
- if (parsed.host && (options.domainHost || (schemeHandler && schemeHandler.domainHost)) && isIP === false && nonSimpleDomain(parsed.host)) {
323
- // convert Unicode IDN -> ASCII IDN
324
- try {
325
- parsed.host = new URL('http://' + parsed.host).hostname
326
- } catch (e) {
327
- parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
328
- }
329
- }
330
- // convert IRI -> URI
331
- }
511
+ // convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts
512
+ malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
332
513
 
333
514
  if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
334
515
  if (uri.indexOf('%') !== -1) {
335
- if (parsed.scheme !== undefined) {
336
- parsed.scheme = unescape(parsed.scheme)
337
- }
338
- if (parsed.host !== undefined) {
339
- parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP)
516
+ if (parsed.host !== undefined && !malformedIPLiteral) {
517
+ const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
518
+ parsed.host = reescapeHostDelimiters(host, isIP)
340
519
  }
341
520
  }
342
521
  if (parsed.path) {
343
522
  parsed.path = normalizePathEncoding(parsed.path)
344
523
  }
524
+ if (parsed.query) {
525
+ parsed.query = normalizeQueryFragmentEncoding(parsed.query)
526
+ }
345
527
  if (parsed.fragment) {
346
- try {
347
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment))
348
- } catch {
349
- parsed.error = parsed.error || 'URI malformed'
350
- }
528
+ parsed.fragment = normalizeQueryFragmentEncoding(parsed.fragment)
351
529
  }
352
530
  }
353
531
 
354
532
  // perform scheme specific parsing
355
533
  if (schemeHandler && schemeHandler.parse) {
356
534
  schemeHandler.parse(parsed, options)
535
+ if (schemeHandler === SCHEMES.urn && parsed.nid === undefined) {
536
+ malformedSchemeSpecific = true
537
+ }
357
538
  }
358
539
  } else {
359
540
  parsed.error = parsed.error || 'URI can not be parsed.'
360
541
  }
361
- return { parsed, malformedAuthorityOrPort }
542
+ return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
362
543
  }
363
544
 
364
545
  /**
@@ -382,13 +563,17 @@ function normalizeString (uri, opts) {
382
563
  /**
383
564
  * @param {string} uri
384
565
  * @param {import('./types/index').Options} [opts]
385
- * @returns {{ normalized: string, malformedAuthorityOrPort: boolean }}
566
+ * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
386
567
  */
387
568
  function normalizeStringWithStatus (uri, opts) {
388
- const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts)
569
+ const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
389
570
  return {
390
- normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
391
- malformedAuthorityOrPort
571
+ normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
572
+ malformedAuthorityOrPort,
573
+ malformedPercentEncoding,
574
+ malformedSchemeSpecific,
575
+ malformedHost,
576
+ malformedScheme
392
577
  }
393
578
  }
394
579
 
@@ -398,14 +583,18 @@ function normalizeStringWithStatus (uri, opts) {
398
583
  * @returns {string|undefined}
399
584
  */
400
585
  function normalizeComparableURI (uri, opts) {
401
- if (typeof uri === 'string') {
402
- const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts)
403
- return malformedAuthorityOrPort ? undefined : normalized
586
+ if (typeof uri !== 'string' && typeof uri !== 'object') {
587
+ return undefined
404
588
  }
405
589
 
406
- if (typeof uri === 'object') {
407
- return serialize(uri, opts)
590
+ let value
591
+ try {
592
+ value = typeof uri === 'string' ? uri : serialize(uri, opts)
593
+ } catch {
594
+ return undefined
408
595
  }
596
+ const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
597
+ return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
409
598
  }
410
599
 
411
600
  const fastUri = {
package/lib/schemes.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { isUUID } = require('./utils')
4
- const URN_REG = /([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu
4
+ const URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu
5
5
 
6
6
  const supportedSchemeNames = /** @type {const} */ (['http', 'https', 'ws',
7
7
  'wss', 'urn', 'urn:uuid'])
@@ -113,9 +113,14 @@ function wsSerialize (wsComponent) {
113
113
 
114
114
  // reconstruct path from resource name
115
115
  if (wsComponent.resourceName) {
116
- const [path, query] = wsComponent.resourceName.split('?')
116
+ const queryIndex = wsComponent.resourceName.indexOf('?')
117
+ const path = queryIndex === -1
118
+ ? wsComponent.resourceName
119
+ : wsComponent.resourceName.slice(0, queryIndex)
117
120
  wsComponent.path = (path && path !== '/' ? path : undefined)
118
- wsComponent.query = query
121
+ wsComponent.query = queryIndex === -1
122
+ ? undefined
123
+ : wsComponent.resourceName.slice(queryIndex + 1)
119
124
  wsComponent.resourceName = undefined
120
125
  }
121
126
 
@@ -132,7 +137,7 @@ function urnParse (urnComponent, options) {
132
137
  return urnComponent
133
138
  }
134
139
  const matches = urnComponent.path.match(URN_REG)
135
- if (matches) {
140
+ if (matches && matches[0] === urnComponent.path) {
136
141
  const scheme = options.scheme || urnComponent.scheme || 'urn'
137
142
  urnComponent.nid = matches[1].toLowerCase()
138
143
  urnComponent.nss = matches[2]