fast-uri 4.1.1 → 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.
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,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
  /**
@@ -132,30 +190,39 @@ function serialize (cmpts, opts) {
132
190
  reference: cmpts.reference,
133
191
  resourceName: cmpts.resourceName,
134
192
  secure: cmpts.secure,
193
+ to: cmpts.to,
194
+ subject: cmpts.subject,
195
+ body: cmpts.body,
196
+ headers: cmpts.headers,
135
197
  error: ''
136
198
  }
137
199
  const options = Object.assign({}, opts)
138
200
  const uriTokens = []
139
201
 
202
+ if (component.scheme) {
203
+ component.scheme = decodeValidScheme(component.scheme)
204
+ }
205
+
140
206
  // find scheme handler
141
207
  const schemeHandler = getSchemeHandler(options.scheme || component.scheme)
142
208
 
143
209
  // perform scheme specific serialization
144
210
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options)
145
211
 
212
+ const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
213
+ const pathNoScheme = !options.skipEscape && component.scheme === undefined && !hasAuthority
214
+
146
215
  if (component.path !== undefined) {
147
216
  if (!options.skipEscape) {
148
- component.path = escapePreservingEscapes(component.path)
149
-
150
- if (component.scheme !== undefined) {
151
- component.path = component.path.split('%3A').join(':')
152
- }
217
+ component.path = serializePathEncoding(component.path, pathNoScheme)
153
218
  } else {
154
219
  component.path = normalizePercentEncoding(component.path)
155
220
  }
156
221
  }
157
222
 
158
223
  if (options.reference !== 'suffix' && component.scheme) {
224
+ // Scheme handlers may replace the scheme during serialization.
225
+ component.scheme = decodeValidScheme(component.scheme)
159
226
  uriTokens.push(component.scheme, ':')
160
227
  }
161
228
 
@@ -178,6 +245,13 @@ function serialize (cmpts, opts) {
178
245
  s = removeDotSegments(s)
179
246
  }
180
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
+
181
255
  if (
182
256
  authority === undefined &&
183
257
  s[0] === '/' &&
@@ -191,11 +265,11 @@ function serialize (cmpts, opts) {
191
265
  }
192
266
 
193
267
  if (component.query !== undefined) {
194
- uriTokens.push('?', component.query)
268
+ uriTokens.push('?', encodeQuery(component.query))
195
269
  }
196
270
 
197
271
  if (component.fragment !== undefined) {
198
- uriTokens.push('#', component.fragment)
272
+ uriTokens.push('#', encodeFragment(component.fragment))
199
273
  }
200
274
  return uriTokens.join('')
201
275
  }
@@ -206,6 +280,15 @@ const URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:
206
280
  // with or without a scheme prefix, for the literal-backslash rejection below.
207
281
  const AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/
208
282
 
283
+ // Captures the leading authority-introducer region after an optional scheme: a
284
+ // run of forward slashes, backslashes, and the characters the WHATWG URL parser
285
+ // removes before parsing (TAB U+0009, LF U+000A, CR U+000D). A valid introducer
286
+ // is exactly "//". Node treats "\" as "/" on special schemes and strips those
287
+ // characters first, so forms like "\\", "/\", "\/", "/<TAB>/", or a leading
288
+ // "<TAB>//" reach an authority in Node while fast-uri's URI_PARSE folds them into
289
+ // the path group (host confusion / SSRF / redirect bypass).
290
+ const AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/
291
+
209
292
  /**
210
293
  * @param {import('./types/index').URIComponent} parsed
211
294
  * @param {RegExpMatchArray} matches
@@ -223,10 +306,74 @@ function getParseError (parsed, matches) {
223
306
  return undefined
224
307
  }
225
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
+
226
373
  /**
227
374
  * @param {string} uri
228
375
  * @param {import('./types/index').Options} [opts]
229
- * @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 }}
230
377
  */
231
378
  function parseWithStatus (uri, opts) {
232
379
  const options = Object.assign({}, opts)
@@ -242,6 +389,11 @@ function parseWithStatus (uri, opts) {
242
389
  }
243
390
 
244
391
  let malformedAuthorityOrPort = false
392
+ let malformedPercentEncoding = false
393
+ let malformedSchemeSpecific = false
394
+ let malformedHost = false
395
+ let malformedIPLiteral = false
396
+ let malformedScheme = false
245
397
 
246
398
  let isIP = false
247
399
  if (options.reference === 'suffix') {
@@ -265,11 +417,33 @@ function parseWithStatus (uri, opts) {
265
417
  malformedAuthorityOrPort = true
266
418
  }
267
419
 
420
+ // Reject a malformed or whitespace-smuggled authority introducer. fast-uri
421
+ // only recognizes a literal "//"; anything else in the leading separator run
422
+ // (a backslash, or a "//" that appears only after removing the TAB/LF/CR that
423
+ // Node strips) means the authority fast-uri parses differs from the one Node's
424
+ // URL resolves. Reject rather than rewrite, mirroring the literal-backslash
425
+ // guard above. Percent-encoded forms (%5C, %09) are untouched, valid data.
426
+ const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION)
427
+ if (introducerMatch !== null) {
428
+ const region = introducerMatch[1]
429
+ const normalizedRegion = region.replace(/[\t\n\r]/g, '')
430
+ // Two or more leading separators introduce an authority.
431
+ if (normalizedRegion.length >= 2) {
432
+ if (normalizedRegion.slice(0, 2) !== '//') {
433
+ parsed.error = parsed.error || 'URI authority must not contain a literal backslash.'
434
+ malformedAuthorityOrPort = true
435
+ } else if (region.length !== normalizedRegion.length) {
436
+ parsed.error = parsed.error || 'URI authority introducer must not contain whitespace.'
437
+ malformedAuthorityOrPort = true
438
+ }
439
+ }
440
+ }
441
+
268
442
  const matches = uri.match(URI_PARSE)
269
443
 
270
444
  if (matches) {
271
445
  // store each component
272
- parsed.scheme = matches[1] === undefined ? undefined : matches[1].toLowerCase()
446
+ parsed.scheme = matches[1]
273
447
  parsed.userinfo = matches[3]
274
448
  parsed.host = matches[4]
275
449
  parsed.port = parseInt(matches[5], 10)
@@ -277,6 +451,21 @@ function parseWithStatus (uri, opts) {
277
451
  parsed.query = matches[7]
278
452
  parsed.fragment = matches[8]
279
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
+
280
469
  // fix port number
281
470
  if (isNaN(parsed.port)) {
282
471
  parsed.port = matches[5]
@@ -291,9 +480,16 @@ function parseWithStatus (uri, opts) {
291
480
  if (parsed.host) {
292
481
  const ipv4result = isIPv4(parsed.host)
293
482
  if (ipv4result === false) {
483
+ const bracketedIPLiteral = parsed.host[0] === '[' && parsed.host[parsed.host.length - 1] === ']'
294
484
  const ipv6result = normalizeIPv6(parsed.host)
295
- parsed.host = ipv6result.host.toLowerCase()
296
- 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
+ }
297
493
  } else {
298
494
  isIP = true
299
495
  }
@@ -316,27 +512,14 @@ function parseWithStatus (uri, opts) {
316
512
  // find scheme handler
317
513
  const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme)
318
514
 
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
- }
515
+ // convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts
516
+ malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
332
517
 
333
518
  if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
334
519
  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)
520
+ if (parsed.host !== undefined && !malformedIPLiteral) {
521
+ const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
522
+ parsed.host = reescapeHostDelimiters(host, isIP)
340
523
  }
341
524
  }
342
525
  if (parsed.path) {
@@ -353,11 +536,14 @@ function parseWithStatus (uri, opts) {
353
536
  // perform scheme specific parsing
354
537
  if (schemeHandler && schemeHandler.parse) {
355
538
  schemeHandler.parse(parsed, options)
539
+ if (schemeHandler === SCHEMES.urn && parsed.nid === undefined) {
540
+ malformedSchemeSpecific = true
541
+ }
356
542
  }
357
543
  } else {
358
544
  parsed.error = parsed.error || 'URI can not be parsed.'
359
545
  }
360
- return { parsed, malformedAuthorityOrPort }
546
+ return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
361
547
  }
362
548
 
363
549
  /**
@@ -381,13 +567,17 @@ function normalizeString (uri, opts) {
381
567
  /**
382
568
  * @param {string} uri
383
569
  * @param {import('./types/index').Options} [opts]
384
- * @returns {{ normalized: string, malformedAuthorityOrPort: boolean }}
570
+ * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
385
571
  */
386
572
  function normalizeStringWithStatus (uri, opts) {
387
- const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts)
573
+ const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
388
574
  return {
389
- normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
390
- malformedAuthorityOrPort
575
+ normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
576
+ malformedAuthorityOrPort,
577
+ malformedPercentEncoding,
578
+ malformedSchemeSpecific,
579
+ malformedHost,
580
+ malformedScheme
391
581
  }
392
582
  }
393
583
 
@@ -397,14 +587,18 @@ function normalizeStringWithStatus (uri, opts) {
397
587
  * @returns {string|undefined}
398
588
  */
399
589
  function normalizeComparableURI (uri, opts) {
400
- if (typeof uri === 'string') {
401
- const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts)
402
- return malformedAuthorityOrPort ? undefined : normalized
590
+ if (typeof uri !== 'string' && typeof uri !== 'object') {
591
+ return undefined
403
592
  }
404
593
 
405
- if (typeof uri === 'object') {
406
- return serialize(uri, opts)
594
+ let value
595
+ try {
596
+ value = typeof uri === 'string' ? uri : serialize(uri, opts)
597
+ } catch {
598
+ return undefined
407
599
  }
600
+ const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
601
+ return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
408
602
  }
409
603
 
410
604
  const fastUri = {