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.
package/lib/schemes.js CHANGED
@@ -1,10 +1,10 @@
1
1
  'use strict'
2
2
 
3
- const { isUUID } = require('./utils')
4
- const URN_REG = /([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu
3
+ const { isUUID, BYTE_HEX, percentEncodeNonAscii, nonSimpleMailtoDomain } = require('./utils')
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
- 'wss', 'urn', 'urn:uuid'])
7
+ 'wss', 'urn', 'urn:uuid', 'mailto'])
8
8
 
9
9
  /** @typedef {supportedSchemeNames[number]} SchemeName */
10
10
 
@@ -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]
@@ -235,13 +240,385 @@ const urnuuid = /** @type {SchemeHandler} */ ({
235
240
  skipNormalize: true
236
241
  })
237
242
 
243
+ /**
244
+ * Allow-sets for `encodeWithAllow`, each a `{ all, table, allowNonAscii }`
245
+ * descriptor built by `allowSet`:
246
+ *
247
+ * - `LOCAL_PART` is the intersection of upstream uri-js's VCHAR and
248
+ * NOT_PATH_NOSCHEME, minus ",": the serializer joins recipients with "," and
249
+ * `mailtoParse` splits the path on it, so a comma left literal inside a local
250
+ * part would silently become a recipient delimiter.
251
+ * - `HFNAME` is the qchar allow-set used for header names/values.
252
+ * - `DTEXT` is RFC 6068 dtext-no-obs restricted to characters that are also
253
+ * safe in a URI path; `DTEXT_IRI` additionally permits non-ASCII for
254
+ * `unicodeSupport`.
255
+ */
256
+ const LOCAL_PART_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+="
257
+ const HFNAME_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+,;:@"
258
+ const DTEXT_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+;:"
259
+
260
+ /**
261
+ * @typedef {Object} AllowSet
262
+ * @property {RegExp} all - matches a whole string needing no encoding at all.
263
+ * @property {Uint8Array} table - per-ASCII-code allow lookup for the slow path.
264
+ * @property {boolean} allowNonAscii - pass non-ASCII code points through (IRI).
265
+ */
266
+
267
+ /**
268
+ * `all` deliberately excludes "%" and (for the IRI variant) surrogates, so any
269
+ * input needing escape normalization or surrogate repair falls to the slow path.
270
+ *
271
+ * @param {string} chars
272
+ * @param {boolean} [allowNonAscii=false]
273
+ * @returns {AllowSet}
274
+ */
275
+ function allowSet (chars, allowNonAscii = false) {
276
+ const table = new Uint8Array(128)
277
+ for (let i = 0; i < chars.length; i++) {
278
+ table[chars.charCodeAt(i)] = 1
279
+ }
280
+ const escaped = chars.replace(/[\\\]^-]/gu, '\\$&')
281
+ const cls = allowNonAscii ? `[${escaped}]|[^\\0-\\x7F\\uD800-\\uDFFF]` : `[${escaped}]`
282
+ return { all: new RegExp(`^(?:${cls})*$`, 'u'), table, allowNonAscii }
283
+ }
284
+
285
+ const LOCAL_PART = allowSet(LOCAL_PART_ALLOWED)
286
+ const HFNAME = allowSet(HFNAME_ALLOWED)
287
+ const DTEXT = allowSet(DTEXT_ALLOWED)
288
+ const DTEXT_IRI = allowSet(DTEXT_ALLOWED, true)
289
+
290
+ const HEX_PAIR = /^[\da-f]{2}$/iu
291
+ const MAILTO_DOMAIN_LITERAL = /^\[[\x21-\x5A\x5E-\x7E]*\]$/u
292
+ const MAILTO_DOMAIN_ERROR = 'URI mailto has an invalid recipient domain.'
293
+ const HAS_SURROGATE = /[\uD800-\uDFFF]/u
294
+
295
+ /**
296
+ * @param {string} str
297
+ * @returns {string}
298
+ */
299
+ function decodeHex (str) {
300
+ if (typeof str !== 'string' || str.indexOf('%') === -1) {
301
+ return str
302
+ }
303
+ try {
304
+ return decodeURIComponent(str)
305
+ } catch {
306
+ return str
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Replaces lone surrogates with U+FFFD, leaving valid pairs intact. Only called
312
+ * when `HAS_SURROGATE` matched, so the scan cost is not on the common path.
313
+ * (`String.prototype.toWellFormed` would do this natively but is Node 20+.)
314
+ *
315
+ * @param {string} input
316
+ * @returns {string}
317
+ */
318
+ function replaceLoneSurrogates (input) {
319
+ let result = ''
320
+ for (let i = 0; i < input.length; i++) {
321
+ const code = input.charCodeAt(i)
322
+ if (code >= 0xD800 && code <= 0xDBFF && i + 1 < input.length) {
323
+ const low = input.charCodeAt(i + 1)
324
+ if (low >= 0xDC00 && low <= 0xDFFF) {
325
+ result += input[i] + input[i + 1]
326
+ i++
327
+ continue
328
+ }
329
+ }
330
+ result += (code >= 0xD800 && code <= 0xDFFF) ? '\uFFFD' : input[i]
331
+ }
332
+ return result
333
+ }
334
+
335
+ /**
336
+ * Percent-encodes everything outside `set`, preserving existing valid escapes
337
+ * (uppercased) and repairing lone surrogates.
338
+ *
339
+ * ASCII uses `BYTE_HEX` rather than `encodeURIComponent`. That is equivalent
340
+ * only because every character `encodeURIComponent` leaves unescaped
341
+ * (`A-Za-z0-9-_.!~*'()`) is present in all of the allow-sets above, so no such
342
+ * character ever reaches the encode branch. Re-check if a set is narrowed.
343
+ *
344
+ * @param {string} input
345
+ * @param {AllowSet} set
346
+ * @returns {string}
347
+ */
348
+ function encodeWithAllow (input, set) {
349
+ if (set.all.test(input)) {
350
+ return input
351
+ }
352
+
353
+ const table = set.table
354
+ let result = ''
355
+ for (let i = 0; i < input.length; i++) {
356
+ const code = input.charCodeAt(i)
357
+
358
+ if (code < 0x80) {
359
+ if (table[code] === 1) {
360
+ result += input[i]
361
+ } else if (code === 0x25 && i + 2 < input.length && HEX_PAIR.test(input.slice(i + 1, i + 3))) {
362
+ result += '%' + input.slice(i + 1, i + 3).toUpperCase()
363
+ i += 2
364
+ } else {
365
+ result += BYTE_HEX[code]
366
+ }
367
+ continue
368
+ }
369
+
370
+ if (code < 0xD800 || code > 0xDFFF) {
371
+ result += set.allowNonAscii ? input[i] : percentEncodeNonAscii(code)
372
+ continue
373
+ }
374
+
375
+ if (code <= 0xDBFF && i + 1 < input.length) {
376
+ const low = input.charCodeAt(i + 1)
377
+ if (low >= 0xDC00 && low <= 0xDFFF) {
378
+ result += set.allowNonAscii
379
+ ? input[i] + input[i + 1]
380
+ : percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
381
+ i++
382
+ continue
383
+ }
384
+ }
385
+
386
+ result += set.allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
387
+ }
388
+ return result
389
+ }
390
+
391
+ /**
392
+ * @param {string} domain
393
+ * @param {import('../types/index').Options} [options]
394
+ * @param {{error?:string}} [component]
395
+ * @returns {string}
396
+ */
397
+ function mailtoNormalizeDomain (domain, options, component) {
398
+ const normalizedDomain = String(domain).toLowerCase()
399
+
400
+ if (MAILTO_DOMAIN_LITERAL.test(normalizedDomain)) {
401
+ return normalizedDomain
402
+ }
403
+
404
+ // `hostname` is the identity for these, so skip constructing a WHATWG URL.
405
+ if (normalizedDomain !== '' && !nonSimpleMailtoDomain(normalizedDomain)) {
406
+ return normalizedDomain
407
+ }
408
+
409
+ try {
410
+ const parsedDomain = new URL('http://' + normalizedDomain)
411
+ if (
412
+ parsedDomain.username ||
413
+ parsedDomain.password ||
414
+ parsedDomain.port ||
415
+ parsedDomain.pathname !== '/' ||
416
+ parsedDomain.search ||
417
+ parsedDomain.hash ||
418
+ !parsedDomain.hostname
419
+ ) {
420
+ throw new Error(MAILTO_DOMAIN_ERROR)
421
+ }
422
+
423
+ return options && options.unicodeSupport
424
+ ? normalizedDomain
425
+ : parsedDomain.hostname
426
+ } catch {
427
+ if (component) {
428
+ component.error = component.error || MAILTO_DOMAIN_ERROR
429
+ }
430
+ return normalizedDomain
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Percent-encodes everything in a recipient domain that is not `dtext-no-obs`
436
+ * (RFC 6068). Serialize-only: `mailtoNormalizeDomain` returns the domain
437
+ * verbatim when validation fails, and `MAILTO_DOMAIN_LITERAL` accepts the full
438
+ * RFC 5321 dcontent range (which includes "?", "#", "&" and "/"), so without
439
+ * this an attacker-supplied recipient could inject header fields or a fragment
440
+ * into the serialized URI. `mailtoParse` deliberately reports the raw domain
441
+ * instead, so it must not use this.
442
+ * @param {string} domain
443
+ * @param {import('../types/index').Options} [options]
444
+ * @returns {string}
445
+ */
446
+ function mailtoEncodeDomain (domain, options) {
447
+ const set = options && options.unicodeSupport ? DTEXT_IRI : DTEXT
448
+
449
+ // Keep the delimiters of a domain literal, encode its contents.
450
+ if (domain.length > 1 && domain[0] === '[' && domain[domain.length - 1] === ']') {
451
+ return '[' + encodeWithAllow(domain.slice(1, -1), set) + ']'
452
+ }
453
+
454
+ return encodeWithAllow(domain, set)
455
+ }
456
+
457
+ /**
458
+ * @param {import('../types/index').URIComponent} component
459
+ * @param {import('../types/index').Options} options
460
+ * @returns {import('../types/index').URIComponent}
461
+ */
462
+ function mailtoParse (component, options) {
463
+ const mailtoComponent = component
464
+
465
+ // The handler sets `skipNormalize`, so path and query arrive raw. Everything
466
+ // here goes through `decodeHex`, which subsumes the generic normalizers --
467
+ // except that they also fold lone surrogates to U+FFFD, so do that here.
468
+ let rawPath = mailtoComponent.path
469
+ let rawQuery = mailtoComponent.query
470
+ if (rawPath && HAS_SURROGATE.test(rawPath)) rawPath = replaceLoneSurrogates(rawPath)
471
+ if (rawQuery && HAS_SURROGATE.test(rawQuery)) rawQuery = replaceLoneSurrogates(rawQuery)
472
+
473
+ const to = rawPath
474
+ ? (rawPath.indexOf(',') === -1 ? [rawPath] : rawPath.split(','))
475
+ : []
476
+ mailtoComponent.path = undefined
477
+
478
+ if (rawQuery) {
479
+ // Null prototype: header names come from untrusted input, so `headers[name]`
480
+ // must not resolve to inherited members ("constructor", "toString", ...),
481
+ // and "__proto__" has to land as a plain own property instead of hitting
482
+ // Object.prototype's setter and being silently dropped. Allocated lazily so
483
+ // the common subject/body-only URI does not pay for it.
484
+ /** @type {Record<string,string>|null} */
485
+ let headers = null
486
+ let start = 0
487
+ while (start <= rawQuery.length) {
488
+ let end = rawQuery.indexOf('&', start)
489
+ if (end === -1) end = rawQuery.length
490
+ const token = rawQuery.slice(start, end)
491
+ start = end + 1
492
+
493
+ const eqIdx = token.indexOf('=')
494
+ if (eqIdx !== token.lastIndexOf('=')) {
495
+ mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.'
496
+ continue
497
+ }
498
+ const name = eqIdx === -1 ? token : token.slice(0, eqIdx)
499
+ const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1)
500
+ if (name === 'to') {
501
+ const addrs = value.split(',')
502
+ for (let j = 0; j < addrs.length; j++) to.push(addrs[j])
503
+ continue
504
+ }
505
+ if (name === 'subject') {
506
+ mailtoComponent.subject = decodeHex(value)
507
+ continue
508
+ }
509
+ if (name === 'body') {
510
+ mailtoComponent.body = decodeHex(value)
511
+ continue
512
+ }
513
+ if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null))
514
+ headers[decodeHex(name)] = decodeHex(value)
515
+ }
516
+ if (headers !== null) mailtoComponent.headers = headers
517
+ }
518
+
519
+ mailtoComponent.query = undefined
520
+
521
+ for (let i = 0; i < to.length; i++) {
522
+ const rawAddr = to[i]
523
+ const atIdx = rawAddr.lastIndexOf('@')
524
+ if (atIdx < 0) {
525
+ mailtoComponent.error = mailtoComponent.error || MAILTO_DOMAIN_ERROR
526
+ to[i] = decodeHex(rawAddr)
527
+ continue
528
+ }
529
+ const local = decodeHex(rawAddr.slice(0, atIdx))
530
+ const domain = mailtoNormalizeDomain(decodeHex(rawAddr.slice(atIdx + 1)), options, mailtoComponent)
531
+ to[i] = local + '@' + domain
532
+ }
533
+
534
+ if (to.length) mailtoComponent.to = to
535
+
536
+ return mailtoComponent
537
+ }
538
+
539
+ /**
540
+ * @param {import('../types/index').URIComponent} component
541
+ * @param {import('../types/index').Options} options
542
+ * @returns {import('../types/index').URIComponent}
543
+ */
544
+ function mailtoSerialize (component, options) {
545
+ const mailtoComponent = component
546
+ const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : []
547
+ if (to.length) {
548
+ for (let i = 0; i < to.length; i++) {
549
+ const addr = String(to[i])
550
+ const atIdx = addr.lastIndexOf('@')
551
+ const rawLocal = atIdx >= 0 ? addr.slice(0, atIdx) : addr
552
+ const rawDomain = atIdx >= 0 ? addr.slice(atIdx + 1) : ''
553
+ const local = encodeWithAllow(rawLocal, LOCAL_PART)
554
+ const decodedDomain = decodeHex(rawDomain)
555
+ // `component` is intentionally not passed: index.js's `serialize` writes
556
+ // the error onto a private copy and returns only a string, so an error
557
+ // recorded here would be unreachable. Safety comes from encoding instead.
558
+ const normalizedDomain = mailtoNormalizeDomain(decodedDomain, options)
559
+ to[i] = local + '@' + mailtoEncodeDomain(normalizedDomain, options)
560
+ }
561
+ // Every recipient is now fully encoded and only the joining commas are
562
+ // literal, so bypass index.js's generic encoder, whose allow-set is
563
+ // narrower than upstream's and would over-encode.
564
+ mailtoComponent.path = to.join(',')
565
+ options.skipEscape = true
566
+ } else {
567
+ // No recipients: drop any caller-supplied path, matching upstream uri-js.
568
+ // `to` is the only recipient source, and `mailtoParse` always moves the
569
+ // path into it, so a mailto component never legitimately carries one.
570
+ mailtoComponent.path = undefined
571
+ }
572
+
573
+ const headers = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
574
+ ? Object.assign(Object.create(null), mailtoComponent.headers)
575
+ : Object.create(null)
576
+
577
+ if (mailtoComponent.subject) headers.subject = mailtoComponent.subject
578
+ if (mailtoComponent.body) headers.body = mailtoComponent.body
579
+ mailtoComponent.headers = headers
580
+
581
+ // `headers` has a null prototype and was filled via Object.assign, so every
582
+ // enumerable key is an own property and no hasOwnProperty guard is needed.
583
+ let query = ''
584
+ let count = 0
585
+ for (const name in headers) {
586
+ if (count++ !== 0) query += '&'
587
+ query += encodeWithAllow(name, HFNAME) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
588
+ }
589
+
590
+ if (count !== 0) {
591
+ mailtoComponent.query = query
592
+ } else {
593
+ mailtoComponent.headers = undefined
594
+ }
595
+
596
+ return mailtoComponent
597
+ }
598
+
599
+ const mailto = /** @type {SchemeHandler} */ ({
600
+ scheme: 'mailto',
601
+ parse: mailtoParse,
602
+ serialize: mailtoSerialize,
603
+ domainHost: false,
604
+ unicodeSupport: true,
605
+ // `mailtoParse` re-derives every component from the raw path/query via
606
+ // `decodeHex`, which subsumes the generic normalizers, so running them first
607
+ // is wasted work (~28% of parsing a URI with header fields).
608
+ skipNormalize: true,
609
+ // A recipient list has no dot segments to remove, and `removeDotSegments`
610
+ // would rewrite a "./"-prefixed local part.
611
+ absolutePath: true
612
+ })
613
+
238
614
  const SCHEMES = /** @type {Record<SchemeName, SchemeHandler>} */ ({
239
615
  http,
240
616
  https,
241
617
  ws,
242
618
  wss,
243
619
  urn,
244
- 'urn:uuid': urnuuid
620
+ 'urn:uuid': urnuuid,
621
+ mailto
245
622
  })
246
623
 
247
624
  Object.setPrototypeOf(SCHEMES, null)