@openclaw/lobster 2026.9.6 → 2026.9.8

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.
Files changed (64) hide show
  1. package/assets/icon.png +0 -0
  2. package/dist/index.js +8 -294
  3. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/README.md +1 -0
  4. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/SECURITY.md +62 -13
  5. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/index.js +40 -9
  6. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/lib/schemes.js +76 -16
  7. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/lib/utils.js +54 -10
  8. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/package.json +1 -1
  9. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/fixtures/uri-js-parse.json +1 -0
  10. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/ipv6-validation.test.js +130 -0
  11. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/mailto-authority-security.test.js +125 -0
  12. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/mailto-field-name-security.test.js +151 -0
  13. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/security-normalization.test.js +38 -0
  14. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/types/index.d.ts +16 -0
  15. package/node_modules/@clawdbot/lobster/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  16. package/node_modules/@clawdbot/lobster/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  17. package/node_modules/@clawdbot/lobster/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  18. package/node_modules/@clawdbot/lobster/node_modules/yaml/dist/nodes/Alias.js +25 -23
  19. package/node_modules/@clawdbot/lobster/node_modules/yaml/package.json +1 -1
  20. package/node_modules/typebox/build/compile/code.mjs +1 -2
  21. package/node_modules/typebox/build/compile/validator.mjs +5 -1
  22. package/node_modules/typebox/build/format/iri.mjs +16 -9
  23. package/node_modules/typebox/build/guard/emit.mjs +2 -2
  24. package/node_modules/typebox/build/guard/guard.d.mts +7 -5
  25. package/node_modules/typebox/build/guard/guard.mjs +14 -9
  26. package/node_modules/typebox/build/guard/index.mjs +6 -0
  27. package/node_modules/typebox/build/guard/unicode/unicode.d.mts +6 -0
  28. package/node_modules/typebox/build/guard/unicode/unicode.mjs +35 -0
  29. package/node_modules/typebox/build/guard/{string.mjs → unicode/unicode_segment.mjs} +16 -19
  30. package/node_modules/typebox/build/schema/build.mjs +2 -3
  31. package/node_modules/typebox/build/schema/engine/_pathing.d.mts +1 -0
  32. package/node_modules/typebox/build/schema/engine/_pathing.mjs +11 -0
  33. package/node_modules/typebox/build/schema/engine/_refine.mjs +13 -5
  34. package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +2 -1
  35. package/node_modules/typebox/build/schema/engine/dependencies.mjs +2 -1
  36. package/node_modules/typebox/build/schema/engine/dependentSchemas.mjs +2 -1
  37. package/node_modules/typebox/build/schema/engine/dynamicRef.d.mts +1 -1
  38. package/node_modules/typebox/build/schema/engine/dynamicRef.mjs +2 -2
  39. package/node_modules/typebox/build/schema/engine/format.mjs +9 -2
  40. package/node_modules/typebox/build/schema/engine/patternProperties.mjs +3 -2
  41. package/node_modules/typebox/build/schema/engine/properties.mjs +3 -2
  42. package/node_modules/typebox/build/schema/engine/propertyNames.mjs +2 -1
  43. package/node_modules/typebox/build/schema/engine/recursiveRef.d.mts +1 -1
  44. package/node_modules/typebox/build/schema/engine/recursiveRef.mjs +2 -2
  45. package/node_modules/typebox/build/schema/engine/ref.d.mts +1 -1
  46. package/node_modules/typebox/build/schema/engine/ref.mjs +2 -2
  47. package/node_modules/typebox/build/schema/engine/schema.mjs +24 -24
  48. package/node_modules/typebox/build/schema/parse.mjs +3 -0
  49. package/node_modules/typebox/build/schema/pointer/pointer.d.mts +1 -6
  50. package/node_modules/typebox/build/schema/pointer/pointer.mjs +47 -64
  51. package/node_modules/typebox/build/system/settings/internal.d.mts +2 -0
  52. package/node_modules/typebox/build/system/settings/internal.mjs +20 -0
  53. package/node_modules/typebox/build/system/settings/settings.d.mts +11 -0
  54. package/node_modules/typebox/build/system/settings/settings.mjs +14 -0
  55. package/node_modules/typebox/build/type/extends/inference.d.mts +1 -1
  56. package/node_modules/typebox/build/type/extends/inference.mjs +15 -2
  57. package/node_modules/typebox/build/type/extends/tuple.d.mts +2 -1
  58. package/node_modules/typebox/build/type/extends/tuple.mjs +21 -1
  59. package/node_modules/typebox/build/value/parse/parse.mjs +10 -4
  60. package/node_modules/typebox/package.json +1 -1
  61. package/node_modules/typebox/readme.md +3 -1
  62. package/package.json +5 -5
  63. package/dist/runtime-api.js +0 -3
  64. /package/node_modules/typebox/build/guard/{string.d.mts → unicode/unicode_segment.d.mts} +0 -0
@@ -290,6 +290,7 @@ const DTEXT_IRI = allowSet(DTEXT_ALLOWED, true)
290
290
  const HEX_PAIR = /^[\da-f]{2}$/iu
291
291
  const MAILTO_DOMAIN_LITERAL = /^\[[\x21-\x5A\x5E-\x7E]*\]$/u
292
292
  const MAILTO_DOMAIN_ERROR = 'URI mailto has an invalid recipient domain.'
293
+ const MAILTO_AUTHORITY_ERROR = 'URI mailto must not have an authority component.'
293
294
  const HAS_SURROGATE = /[\uD800-\uDFFF]/u
294
295
 
295
296
  /**
@@ -495,29 +496,51 @@ function mailtoParse (component, options) {
495
496
  mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.'
496
497
  continue
497
498
  }
498
- const name = eqIdx === -1 ? token : token.slice(0, eqIdx)
499
+ const name = decodeHex(eqIdx === -1 ? token : token.slice(0, eqIdx))
500
+ const normalizedName = name.toLowerCase()
499
501
  const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1)
500
- if (name === 'to') {
502
+ if (normalizedName === 'to') {
501
503
  const addrs = value.split(',')
502
504
  for (let j = 0; j < addrs.length; j++) to.push(addrs[j])
503
505
  continue
504
506
  }
505
- if (name === 'subject') {
507
+ if (normalizedName === 'subject') {
506
508
  mailtoComponent.subject = decodeHex(value)
507
509
  continue
508
510
  }
509
- if (name === 'body') {
511
+ if (normalizedName === 'body') {
510
512
  mailtoComponent.body = decodeHex(value)
511
513
  continue
512
514
  }
513
515
  if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null))
514
- headers[decodeHex(name)] = decodeHex(value)
516
+ headers[name] = decodeHex(value)
515
517
  }
516
518
  if (headers !== null) mailtoComponent.headers = headers
517
519
  }
518
520
 
519
521
  mailtoComponent.query = undefined
520
522
 
523
+ // RFC 6068 defines no authority for the `mailto` scheme. A `mailto://` input
524
+ // that retains an authority and also carries recipients (from the path or a
525
+ // `to=` hfield) folds a leading "/" into the recipient local part on the
526
+ // next parse; the generic serializer then reinserts a "/" before that path
527
+ // on every pass, so serialize(parse(x)) is not a fixpoint and a
528
+ // renormalize-to-fixpoint loop accretes one "%2F" per pass. Drop the
529
+ // retained authority so the parse -> serialize round trip is stable. A bare
530
+ // `mailto://host` with no recipients is left as-is: it is idempotent and host
531
+ // normalization for skip-normalize schemes stays intact.
532
+ if (
533
+ to.length > 0 &&
534
+ (mailtoComponent.userinfo !== undefined ||
535
+ mailtoComponent.host !== undefined ||
536
+ mailtoComponent.port !== undefined)
537
+ ) {
538
+ mailtoComponent.userinfo = undefined
539
+ mailtoComponent.host = undefined
540
+ mailtoComponent.port = undefined
541
+ mailtoComponent.error = mailtoComponent.error || MAILTO_AUTHORITY_ERROR
542
+ }
543
+
521
544
  for (let i = 0; i < to.length; i++) {
522
545
  const rawAddr = to[i]
523
546
  const atIdx = rawAddr.lastIndexOf('@')
@@ -536,6 +559,18 @@ function mailtoParse (component, options) {
536
559
  return mailtoComponent
537
560
  }
538
561
 
562
+ /**
563
+ * Encodes a decoded mailto header name. Unlike `encodeWithAllow`, a percent
564
+ * sign is always data here, even when followed by two hex digits. Preserving
565
+ * such a sequence as an existing escape would decode the name again on the
566
+ * next parse and could turn a generic header into `to`, `subject`, or `body`.
567
+ * @param {string} name
568
+ * @returns {string}
569
+ */
570
+ function mailtoEncodeHeaderName (name) {
571
+ return encodeWithAllow(name.replace(/%/gu, '%25'), HFNAME)
572
+ }
573
+
539
574
  /**
540
575
  * @param {import('../types/index').URIComponent} component
541
576
  * @param {import('../types/index').Options} options
@@ -544,7 +579,40 @@ function mailtoParse (component, options) {
544
579
  function mailtoSerialize (component, options) {
545
580
  const mailtoComponent = component
546
581
  const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : []
582
+ const sourceHeaders = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
583
+ ? Object.assign(Object.create(null), mailtoComponent.headers)
584
+ : Object.create(null)
585
+ const headers = Object.create(null)
586
+
587
+ // Header field names are case-insensitive. Canonicalize the fields with
588
+ // dedicated component properties so callers cannot smuggle a second spelling
589
+ // through `headers`. As during parsing, all `to` fields are accumulated;
590
+ // repeated subject/body fields use the last value.
591
+ for (const name in sourceHeaders) {
592
+ const normalizedName = name.toLowerCase()
593
+ if (normalizedName === 'to') {
594
+ const addrs = String(sourceHeaders[name]).split(',')
595
+ for (let i = 0; i < addrs.length; i++) to.push(addrs[i])
596
+ } else if (normalizedName === 'subject' || normalizedName === 'body') {
597
+ headers[normalizedName] = sourceHeaders[name]
598
+ } else {
599
+ headers[name] = sourceHeaders[name]
600
+ }
601
+ }
602
+
603
+ if (mailtoComponent.subject !== undefined) headers.subject = mailtoComponent.subject
604
+ if (mailtoComponent.body !== undefined) headers.body = mailtoComponent.body
605
+ mailtoComponent.headers = headers
606
+
547
607
  if (to.length) {
608
+ // RFC 6068 defines no authority for the `mailto` scheme. If an
609
+ // application constructs a component with both an authority and recipients,
610
+ // drop the authority before the generic serializer can insert a "/" path
611
+ // separator that becomes part of the first recipient on the next parse.
612
+ mailtoComponent.userinfo = undefined
613
+ mailtoComponent.host = undefined
614
+ mailtoComponent.port = undefined
615
+
548
616
  for (let i = 0; i < to.length; i++) {
549
617
  const addr = String(to[i])
550
618
  const atIdx = addr.lastIndexOf('@')
@@ -570,21 +638,13 @@ function mailtoSerialize (component, options) {
570
638
  mailtoComponent.path = undefined
571
639
  }
572
640
 
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.
641
+ // `headers` has a null prototype, so every enumerable key is an own property
642
+ // and no hasOwnProperty guard is needed.
583
643
  let query = ''
584
644
  let count = 0
585
645
  for (const name in headers) {
586
646
  if (count++ !== 0) query += '&'
587
- query += encodeWithAllow(name, HFNAME) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
647
+ query += mailtoEncodeHeaderName(name) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
588
648
  }
589
649
 
590
650
  if (count !== 0) {
@@ -740,6 +740,37 @@ function escapePreservingEscapes (input) {
740
740
  return output
741
741
  }
742
742
 
743
+ /**
744
+ * Rebuilds the bracketed form of a zoned IPv6 authority from the current
745
+ * `component.host` and its zone identifier (`component.ipv6Zone`), but only
746
+ * when they are consistent and describe a real zoned IPv6 literal that
747
+ * normalizes back to `component.host`. This validates the zone on use, so a
748
+ * caller-supplied `ipv6Zone` that does not match the host (or a host
749
+ * reassigned after `parse`) is never trusted and recomposition falls back to
750
+ * the normal path.
751
+ *
752
+ * @param {import('../types/index').URIComponent} component
753
+ * @returns {string|undefined}
754
+ */
755
+ function recomposeZonedIPv6Host (component) {
756
+ const zone = component.ipv6Zone
757
+ // Without an ipv6Zone the single-"%" host form is genuinely ambiguous when
758
+ // the zone begins with "25", so we fall through to the best-effort heuristic
759
+ // (which matches the pre-existing behavior for hand-built components that do
760
+ // not carry a zone).
761
+ if (zone === undefined) return undefined
762
+ const host = component.host
763
+ const separator = host.indexOf('%')
764
+ if (separator === -1) return undefined
765
+ const address = host.slice(0, separator)
766
+ const escaped = address + '%25' + zone
767
+ const derived = normalizeIPv6('[' + escaped + ']')
768
+ if (derived.isIPV6 === true && derived.host === host) {
769
+ return '[' + escaped + ']'
770
+ }
771
+ return undefined
772
+ }
773
+
743
774
  /**
744
775
  * @param {import('../types/index').URIComponent} component
745
776
  * @returns {string|undefined}
@@ -755,17 +786,30 @@ function recomposeAuthority (component) {
755
786
  if (component.host !== undefined) {
756
787
  let host = component.host
757
788
  if (!isIPv4(host)) {
758
- let ipV6res = normalizeIPv6(host)
759
- if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
760
- // Decode only unreserved bytes, once. In particular, keep %25 encoded
761
- // so it cannot introduce a second escape during recomposition.
762
- host = normalizePercentEncoding(host, true)
763
- ipV6res = normalizeIPv6(host)
764
- }
765
- if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
766
- host = `[${ipV6res.escapedHost}]`
789
+ // A parse-time zone identifier is authoritative only while it still
790
+ // describes the current host. Re-deriving an IPv6 zone from the
791
+ // single-"%" component form is ambiguous when the zone begins with
792
+ // "25": it is misread as a %25 separator, rewriting e.g. zone
793
+ // "25eth0" into "eth0" (or destroying a zone of just "25"). A
794
+ // validated zone keeps parse, normalize, serialize and equal in
795
+ // agreement.
796
+ const zonedHost = recomposeZonedIPv6Host(component)
797
+ if (zonedHost !== undefined) {
798
+ host = zonedHost
767
799
  } else {
768
- host = reescapeHostDelimiters(host, false)
800
+ let ipV6res = normalizeIPv6(host)
801
+ if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
802
+ // Decode only unreserved bytes, once. In particular, keep %25
803
+ // encoded so it cannot introduce a second escape during
804
+ // recomposition.
805
+ host = normalizePercentEncoding(host, true)
806
+ ipV6res = normalizeIPv6(host)
807
+ }
808
+ if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
809
+ host = `[${ipV6res.escapedHost}]`
810
+ } else {
811
+ host = reescapeHostDelimiters(host, false)
812
+ }
769
813
  }
770
814
  }
771
815
  uriTokens.push(host)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fast-uri",
3
3
  "description": "Dependency-free RFC 3986 URI toolbox",
4
- "version": "4.1.4",
4
+ "version": "4.2.1",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -43,6 +43,7 @@
43
43
  "//[2001:db8::7%en0]",
44
44
  {
45
45
  "host": "2001:db8::7%en0",
46
+ "ipv6Zone": "en0",
46
47
  "path": "",
47
48
  "reference": "relative"
48
49
  }
@@ -119,6 +119,136 @@ test('IPv6 zone identifiers are validated correctly', (t) => {
119
119
  t.end()
120
120
  })
121
121
 
122
+ test('zoned IPv6 hosts beginning with "25" round-trip through parse/normalize/serialize/equal', (t) => {
123
+ const cases = [
124
+ // zones beginning with "25" (once ambiguous with a %25 separator)
125
+ ['http://[fe80::1%2525]/', 'fe80::1%25', 'http://[fe80::1%2525]/'],
126
+ ['http://[fe80::1%2525eth0]/', 'fe80::1%25eth0', 'http://[fe80::1%2525eth0]/'],
127
+ ['http://[fe80::1%2525en1]/', 'fe80::1%25en1', 'http://[fe80::1%2525en1]/'],
128
+ // zones that do not begin with "25" must stay idempotent
129
+ ['http://[fe80::1%25eth0]/', 'fe80::1%eth0', 'http://[fe80::1%25eth0]/'],
130
+ ['http://[fe80::a%en1]/', 'fe80::a%en1', 'http://[fe80::a%25en1]/'],
131
+ ['http://[fe80::a%25eth%2D0]/', 'fe80::a%eth%2D0', 'http://[fe80::a%25eth%2D0]/']
132
+ ]
133
+
134
+ for (const [input, host, normalized] of cases) {
135
+ const parsed = fastURI.parse(input)
136
+ t.equal(parsed.error, undefined, `${input} parses without error`)
137
+ t.equal(parsed.host, host, `${input} exposes the expected host`)
138
+ t.equal(fastURI.normalize(input), normalized, `${input} normalizes to the canonical form`)
139
+ t.equal(fastURI.serialize(parsed), normalized, `${input} serializes to the canonical form`)
140
+ const reparsed = fastURI.parse(normalized)
141
+ t.equal(reparsed.error, undefined, `${input} canonical output reparses without error`)
142
+ t.equal(reparsed.host, host, `${input} parse-after-normalize is stable`)
143
+ }
144
+
145
+ // distinct zones must not be conflated by equal
146
+ t.equal(
147
+ fastURI.equal('http://[fe80::1%2525eth0]/', 'http://[fe80::1%25eth0]/'),
148
+ false,
149
+ 'equal distinguishes zones "25eth0" and "eth0"'
150
+ )
151
+ t.equal(
152
+ fastURI.equal('http://[fe80::1%2525]/', 'http://[fe80::1%25eth0]/'),
153
+ false,
154
+ 'equal distinguishes zones "25" and "eth0"'
155
+ )
156
+ // the same zone still compares equal
157
+ t.equal(
158
+ fastURI.equal('http://[fe80::1%2525eth0]/', 'http://[fe80::1%2525eth0]/'),
159
+ true,
160
+ 'an identical zoned authority is equal'
161
+ )
162
+ t.equal(
163
+ fastURI.equal('http://[fe80::1%2525]/', 'http://[fe80::1%2525]/'),
164
+ true,
165
+ 'zone "25" equals itself'
166
+ )
167
+
168
+ // resolve must preserve the zone too
169
+ t.equal(
170
+ fastURI.resolve('http://example.com/', 'http://[fe80::1%2525eth0]/path'),
171
+ 'http://[fe80::1%2525eth0]/path',
172
+ 'resolve preserves a zone beginning with "25"'
173
+ )
174
+ t.equal(
175
+ fastURI.resolve('http://[fe80::1%2525eth0]/', 'child'),
176
+ 'http://[fe80::1%2525eth0]/child',
177
+ 'resolve preserves a zoned base'
178
+ )
179
+
180
+ t.end()
181
+ })
182
+
183
+ test('serialize only trusts the parse-derived IPv6 zone while the host is unchanged', (t) => {
184
+ // Reassigning `host` after parse must take effect for serialize.
185
+ const parsed = fastURI.parse('http://[::1]/admin')
186
+ parsed.host = 'example.com'
187
+ t.equal(
188
+ fastURI.serialize(parsed),
189
+ 'http://example.com/admin',
190
+ 'a host reassigned after parse wins over the stale zone'
191
+ )
192
+
193
+ // A caller-supplied `ipv6Zone` that does not match `host` must be ignored.
194
+ const forged = fastURI.serialize({
195
+ scheme: 'http',
196
+ host: 'fe80::1%25en1',
197
+ ipv6Zone: 'eth0',
198
+ path: '/'
199
+ })
200
+ t.equal(forged, 'http://[fe80::1%25en1]/', 'a mismatched ipv6Zone is ignored')
201
+ t.equal(fastURI.parse(forged).host, 'fe80::1%en1', 'mismatched ipv6Zone cannot redirect the parsed host')
202
+
203
+ // A caller-supplied `ipv6Zone` on a non-zoned host must be ignored too.
204
+ const noZone = fastURI.serialize({
205
+ scheme: 'http',
206
+ host: 'good.example',
207
+ ipv6Zone: 'eth0',
208
+ path: '/'
209
+ })
210
+ t.equal(noZone, 'http://good.example/', 'ipv6Zone on a non-zoned host is ignored')
211
+
212
+ // Reassigning a zoned host to a different zone must recompute, not reuse.
213
+ const zoned = fastURI.parse('http://[fe80::1%2525eth0]/')
214
+ zoned.host = 'fe80::1%25en1'
215
+ t.equal(
216
+ fastURI.serialize(zoned),
217
+ 'http://[fe80::1%25en1]/',
218
+ 'rewriting the zone recomposes from the new host'
219
+ )
220
+
221
+ // A consistent caller-supplied zone is honored (explicit disambiguation).
222
+ const disambiguated = fastURI.serialize({
223
+ scheme: 'http',
224
+ host: 'fe80::1%25eth0',
225
+ ipv6Zone: '25eth0',
226
+ path: '/'
227
+ })
228
+ t.equal(
229
+ disambiguated,
230
+ 'http://[fe80::1%2525eth0]/',
231
+ 'a consistent ipv6Zone disambiguates the component host'
232
+ )
233
+
234
+ // Without ipv6Zone, an ambiguous single-"%" host falls back to the
235
+ // best-effort heuristic (documented behavior for hand-built components).
236
+ t.equal(
237
+ fastURI.serialize({ host: 'fe80::1%25eth0' }),
238
+ '//[fe80::1%25eth0]',
239
+ 'an ambiguous host without ipv6Zone uses the best-effort heuristic'
240
+ )
241
+
242
+ // Mutating the host must not poison resolve either.
243
+ t.equal(
244
+ fastURI.resolve('http://example.com/', 'http://[fe80::1%2525eth0]/path'),
245
+ 'http://[fe80::1%2525eth0]/path',
246
+ 'resolve preserves the parse-derived zone'
247
+ )
248
+
249
+ t.end()
250
+ })
251
+
122
252
  test('hosts with unbalanced or misplaced IP-literal brackets are rejected', (t) => {
123
253
  const malformed = [
124
254
  'http://[fe80',
@@ -0,0 +1,125 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ // GHSA-88VG-QFHM-JX5Q: `mailto://h/a` carries an authority (which RFC 6068 does
7
+ // not define for `mailto`) and a path. `mailtoParse` used to retain that
8
+ // authority and fold the path's leading "/" into the recipient local part; the
9
+ // generic serializer then reinserted a "/" separator on every pass, so
10
+ // serialize(parse(x)) was not a fixpoint and normalize() accreted one "%2F" per
11
+ // pass (unbounded growth for a renormalize-to-fixpoint consumer).
12
+
13
+ const authorityForms = [
14
+ ['mailto://h/a', 'mailto:%2Fa@'],
15
+ ['mailto://h/a@b', 'mailto:%2Fa@b'],
16
+ ['mailto://h/', 'mailto:%2F@'],
17
+ ['mailto://h/a,b', 'mailto:%2Fa@,b@'],
18
+ ['mailto://user@h/a', 'mailto:%2Fa@'],
19
+ ['mailto://h:8080/a', 'mailto:%2Fa@'],
20
+ ['mailto://', 'mailto://'],
21
+ // recipients can come from a `to=` hfield even when the path is empty
22
+ ['mailto://h?to=x@y', 'mailto:x@y'],
23
+ ['mailto://h?to=x@y&to=a@b', 'mailto:x@y,a@b']
24
+ ]
25
+
26
+ test('mailto with a retained authority and a path normalizes to a fixpoint', (t) => {
27
+ t.plan(authorityForms.length * 2)
28
+
29
+ for (const [input] of authorityForms) {
30
+ const once = fastURI.normalize(input)
31
+ const twice = fastURI.normalize(once)
32
+
33
+ t.equal(twice, once, input + ' normalize is idempotent')
34
+ t.equal(fastURI.equal(input, once), true, input + ' equals its normalized form')
35
+ }
36
+ })
37
+
38
+ test('mailto serialize(parse()) is a stable round trip for the authority form', (t) => {
39
+ t.plan(authorityForms.length)
40
+
41
+ for (const [input, expected] of authorityForms) {
42
+ t.equal(fastURI.serialize(fastURI.parse(input)), expected, input + ' round trips to a stable value')
43
+ }
44
+ })
45
+
46
+ test('mailto authority form does not grow without bound under renormalization', (t) => {
47
+ // A renormalize-to-fixpoint loop must terminate instead of growing forever.
48
+ // A single normalize call already terminates, so this guards the fixpoint.
49
+ const inputs = ['mailto://h/a', 'mailto://h/a@b', 'mailto://h/a,b']
50
+
51
+ t.plan(inputs.length * 2)
52
+
53
+ for (const input of inputs) {
54
+ const first = fastURI.normalize(input)
55
+ let s = input
56
+ let iters = 0
57
+ let stable = false
58
+ while (iters < 1000) {
59
+ const next = fastURI.normalize(s)
60
+ if (next === s) {
61
+ stable = true
62
+ break
63
+ }
64
+ s = next
65
+ iters++
66
+ }
67
+
68
+ t.equal(stable, true, input + ' reaches a fixpoint')
69
+ t.equal(s.length, first.length, input + ' does not grow')
70
+ }
71
+ })
72
+
73
+ test('mailto authority handling preserves host normalization for skip-normalize schemes', (t) => {
74
+ // Regression: a bare `mailto://host` with no path must keep its host and stay
75
+ // idempotent, so the generic host normalization path is unaffected.
76
+ const encodedHost = 'mailto://%41.com'
77
+ const literalHost = 'mailto://a.com'
78
+
79
+ t.equal(fastURI.parse(encodedHost).host, fastURI.parse(literalHost).host, 'parse results have consistent hosts')
80
+ t.equal(fastURI.normalize(encodedHost), fastURI.normalize(literalHost), 'normalize results are consistent')
81
+ t.equal(fastURI.equal(encodedHost, literalHost), true, 'encoded and literal hosts compare equal')
82
+ t.equal(fastURI.normalize(literalHost), literalHost, 'bare authority mailto is idempotent')
83
+ t.end()
84
+ })
85
+
86
+ test('mailto authority form is reported as malformed on the parsed component', (t) => {
87
+ t.equal(
88
+ fastURI.parse('mailto://h/a').error,
89
+ 'URI mailto must not have an authority component.',
90
+ 'the retained authority is surfaced as an error'
91
+ )
92
+
93
+ // A valid, authority-free mailto must not be flagged.
94
+ t.equal(fastURI.parse('mailto:user@host.com').error, undefined, 'well-formed mailto has no error')
95
+ t.end()
96
+ })
97
+
98
+ test('mailto serializer drops caller-supplied authority when recipients are present', (t) => {
99
+ t.equal(
100
+ fastURI.serialize({ scheme: 'mailto', host: 'h', to: ['a@b'] }),
101
+ 'mailto:a@b',
102
+ 'authority is dropped for direct recipients'
103
+ )
104
+ t.equal(
105
+ fastURI.serialize({ scheme: 'mailto', userinfo: 'u', host: 'h', port: 8080, to: ['a@b'] }),
106
+ 'mailto:a@b',
107
+ 'userinfo, host, and port are dropped for direct recipients'
108
+ )
109
+ t.equal(
110
+ fastURI.serialize({ scheme: 'mailto', host: 'h', headers: { to: 'a@b' } }),
111
+ 'mailto:a@b',
112
+ 'authority is dropped for recipients supplied through a to hfield'
113
+ )
114
+ t.equal(
115
+ fastURI.serialize({ scheme: 'mailto', host: 'h', to: ['a@b'], subject: 'hello' }),
116
+ 'mailto:a@b?subject=hello',
117
+ 'dropping authority preserves header fields'
118
+ )
119
+ t.equal(
120
+ fastURI.serialize({ scheme: 'mailto', host: 'h' }),
121
+ 'mailto://h',
122
+ 'bare authority without recipients remains idempotent'
123
+ )
124
+ t.end()
125
+ })
@@ -0,0 +1,151 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ function encodeCharacter (value, index, lowercaseHex) {
7
+ let hex = value.charCodeAt(index).toString(16)
8
+ if (!lowercaseHex) hex = hex.toUpperCase()
9
+ return value.slice(0, index) + '%' + hex + value.slice(index + 1)
10
+ }
11
+
12
+ function mailtoState (component) {
13
+ return {
14
+ to: component.to,
15
+ subject: component.subject,
16
+ body: component.body,
17
+ headers: component.headers && { ...component.headers }
18
+ }
19
+ }
20
+
21
+ test('mailto parsing classifies decoded, case-insensitive reserved field names', (t) => {
22
+ const cases = [
23
+ { name: 'to', value: 'attacker@evil.test', property: 'to' },
24
+ { name: 'subject', value: 'injected subject', property: 'subject' },
25
+ { name: 'body', value: 'injected body', property: 'body' }
26
+ ]
27
+
28
+ for (const entry of cases) {
29
+ const variants = new Set([entry.name.toUpperCase()])
30
+ for (let i = 0; i < entry.name.length; i++) {
31
+ variants.add(encodeCharacter(entry.name, i, false))
32
+ variants.add(encodeCharacter(entry.name, i, true))
33
+ }
34
+
35
+ for (const name of variants) {
36
+ const parsed = fastURI.parse(`mailto:alice@example.test?${name}=${entry.value}`)
37
+ if (entry.property === 'to') {
38
+ t.deepEqual(parsed.to, ['alice@example.test', entry.value], name + ' is classified as a recipient')
39
+ } else {
40
+ t.equal(parsed[entry.property], entry.value, name + ' is classified as ' + entry.property)
41
+ }
42
+ t.equal(parsed.headers, undefined, name + ' is not exposed as a generic header')
43
+
44
+ const reparsed = fastURI.parse(fastURI.serialize(parsed))
45
+ t.deepEqual(mailtoState(reparsed), mailtoState(parsed), name + ' keeps its meaning after a roundtrip')
46
+ }
47
+ }
48
+
49
+ t.end()
50
+ })
51
+
52
+ test('mailto generic field names are decoded exactly once across roundtrips', (t) => {
53
+ const cases = [
54
+ ['%2574o', '%74o'],
55
+ ['%2573ubject', '%73ubject'],
56
+ ['%2562ody', '%62ody'],
57
+ ['x%2520y', 'x%20y'],
58
+ ['%26to', '&to'],
59
+ ['%25GGto', '%GGto']
60
+ ]
61
+
62
+ for (const [encodedName, decodedName] of cases) {
63
+ let serialized = `mailto:alice@example.test?${encodedName}=value`
64
+ for (let round = 0; round < 3; round++) {
65
+ const parsed = fastURI.parse(serialized)
66
+ t.deepEqual({ ...parsed.headers }, { [decodedName]: 'value' }, encodedName + ' stays generic in round ' + round)
67
+ t.deepEqual(parsed.to, ['alice@example.test'], encodedName + ' does not add a recipient in round ' + round)
68
+ t.equal(parsed.subject, undefined, encodedName + ' does not set a subject in round ' + round)
69
+ t.equal(parsed.body, undefined, encodedName + ' does not set a body in round ' + round)
70
+ serialized = fastURI.serialize(parsed)
71
+ }
72
+ t.equal(serialized, `mailto:alice@example.test?${encodedName}=value`, encodedName + ' serializes idempotently')
73
+ }
74
+
75
+ t.equal(
76
+ fastURI.serialize({ scheme: 'mailto', to: ['alice@example.test'], headers: { '%74o': 'value' } }),
77
+ 'mailto:alice@example.test?%2574o=value',
78
+ 'a percent sequence in a decoded component key is encoded as data'
79
+ )
80
+ t.end()
81
+ })
82
+
83
+ test('mailto serialization canonicalizes reserved generic header names', (t) => {
84
+ const serialized = fastURI.serialize({
85
+ scheme: 'mailto',
86
+ to: ['alice@example.test'],
87
+ subject: 'safe subject',
88
+ body: 'safe body',
89
+ headers: {
90
+ TO: 'attacker@evil.test',
91
+ Subject: 'hidden subject',
92
+ BODY: 'hidden body',
93
+ cc: 'copy@example.test'
94
+ }
95
+ })
96
+
97
+ t.equal(
98
+ serialized,
99
+ 'mailto:alice@example.test,attacker@evil.test?subject=safe%20subject&body=safe%20body&cc=copy@example.test',
100
+ 'dedicated properties override header variants and all recipients are visible in the path'
101
+ )
102
+
103
+ const parsed = fastURI.parse(serialized)
104
+ t.deepEqual(parsed.to, ['alice@example.test', 'attacker@evil.test'], 'canonical recipients are classified consistently')
105
+ t.equal(parsed.subject, 'safe subject', 'the dedicated subject wins')
106
+ t.equal(parsed.body, 'safe body', 'the dedicated body wins')
107
+ t.deepEqual({ ...parsed.headers }, { cc: 'copy@example.test' }, 'only generic headers remain generic')
108
+
109
+ const empty = fastURI.serialize({
110
+ scheme: 'mailto',
111
+ to: ['alice@example.test'],
112
+ subject: '',
113
+ body: '',
114
+ headers: { Subject: 'hidden subject', BODY: 'hidden body' }
115
+ })
116
+ t.equal(empty, 'mailto:alice@example.test?subject=&body=', 'explicit empty properties override header variants')
117
+ const emptyParsed = fastURI.parse(empty)
118
+ t.equal(emptyParsed.subject, '', 'an empty subject round-trips')
119
+ t.equal(emptyParsed.body, '', 'an empty body round-trips')
120
+ t.end()
121
+ })
122
+
123
+ test('mailto duplicate reserved fields have deterministic parsing semantics', (t) => {
124
+ const parsed = fastURI.parse(
125
+ 'mailto:alice@example.test?subject=first&%53UBJECT=last&body=first&%42ODY=last&to=one@example.test&%54O=two@example.test'
126
+ )
127
+
128
+ t.equal(parsed.subject, 'last', 'the last subject wins')
129
+ t.equal(parsed.body, 'last', 'the last body wins')
130
+ t.deepEqual(
131
+ parsed.to,
132
+ ['alice@example.test', 'one@example.test', 'two@example.test'],
133
+ 'all recipient fields are accumulated'
134
+ )
135
+ t.equal(parsed.headers, undefined, 'no reserved spelling remains generic')
136
+ t.end()
137
+ })
138
+
139
+ test('encoded reserved field names expose decoded control characters immediately', (t) => {
140
+ const input = 'mailto:alice@example.test?%73ubject=hello%0D%0Aworld'
141
+ const parsed = fastURI.parse(input)
142
+
143
+ t.equal(parsed.subject, 'hello\r\nworld', 'the first parse exposes CRLF in the dedicated property')
144
+ t.equal(parsed.headers, undefined, 'the subject is not hidden as a generic header')
145
+ t.equal(
146
+ fastURI.parse(fastURI.serialize(parsed)).subject,
147
+ parsed.subject,
148
+ 'the decoded subject remains stable after a roundtrip'
149
+ )
150
+ t.end()
151
+ })