@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.
- package/assets/icon.png +0 -0
- package/dist/index.js +8 -294
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/README.md +1 -0
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/SECURITY.md +62 -13
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/index.js +40 -9
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/lib/schemes.js +76 -16
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/lib/utils.js +54 -10
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/package.json +1 -1
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/fixtures/uri-js-parse.json +1 -0
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/ipv6-validation.test.js +130 -0
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/mailto-authority-security.test.js +125 -0
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/mailto-field-name-security.test.js +151 -0
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/security-normalization.test.js +38 -0
- package/node_modules/@clawdbot/lobster/node_modules/fast-uri/types/index.d.ts +16 -0
- package/node_modules/@clawdbot/lobster/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/@clawdbot/lobster/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/@clawdbot/lobster/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/@clawdbot/lobster/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/@clawdbot/lobster/node_modules/yaml/package.json +1 -1
- package/node_modules/typebox/build/compile/code.mjs +1 -2
- package/node_modules/typebox/build/compile/validator.mjs +5 -1
- package/node_modules/typebox/build/format/iri.mjs +16 -9
- package/node_modules/typebox/build/guard/emit.mjs +2 -2
- package/node_modules/typebox/build/guard/guard.d.mts +7 -5
- package/node_modules/typebox/build/guard/guard.mjs +14 -9
- package/node_modules/typebox/build/guard/index.mjs +6 -0
- package/node_modules/typebox/build/guard/unicode/unicode.d.mts +6 -0
- package/node_modules/typebox/build/guard/unicode/unicode.mjs +35 -0
- package/node_modules/typebox/build/guard/{string.mjs → unicode/unicode_segment.mjs} +16 -19
- package/node_modules/typebox/build/schema/build.mjs +2 -3
- package/node_modules/typebox/build/schema/engine/_pathing.d.mts +1 -0
- package/node_modules/typebox/build/schema/engine/_pathing.mjs +11 -0
- package/node_modules/typebox/build/schema/engine/_refine.mjs +13 -5
- package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +2 -1
- package/node_modules/typebox/build/schema/engine/dependencies.mjs +2 -1
- package/node_modules/typebox/build/schema/engine/dependentSchemas.mjs +2 -1
- package/node_modules/typebox/build/schema/engine/dynamicRef.d.mts +1 -1
- package/node_modules/typebox/build/schema/engine/dynamicRef.mjs +2 -2
- package/node_modules/typebox/build/schema/engine/format.mjs +9 -2
- package/node_modules/typebox/build/schema/engine/patternProperties.mjs +3 -2
- package/node_modules/typebox/build/schema/engine/properties.mjs +3 -2
- package/node_modules/typebox/build/schema/engine/propertyNames.mjs +2 -1
- package/node_modules/typebox/build/schema/engine/recursiveRef.d.mts +1 -1
- package/node_modules/typebox/build/schema/engine/recursiveRef.mjs +2 -2
- package/node_modules/typebox/build/schema/engine/ref.d.mts +1 -1
- package/node_modules/typebox/build/schema/engine/ref.mjs +2 -2
- package/node_modules/typebox/build/schema/engine/schema.mjs +24 -24
- package/node_modules/typebox/build/schema/parse.mjs +3 -0
- package/node_modules/typebox/build/schema/pointer/pointer.d.mts +1 -6
- package/node_modules/typebox/build/schema/pointer/pointer.mjs +47 -64
- package/node_modules/typebox/build/system/settings/internal.d.mts +2 -0
- package/node_modules/typebox/build/system/settings/internal.mjs +20 -0
- package/node_modules/typebox/build/system/settings/settings.d.mts +11 -0
- package/node_modules/typebox/build/system/settings/settings.mjs +14 -0
- package/node_modules/typebox/build/type/extends/inference.d.mts +1 -1
- package/node_modules/typebox/build/type/extends/inference.mjs +15 -2
- package/node_modules/typebox/build/type/extends/tuple.d.mts +2 -1
- package/node_modules/typebox/build/type/extends/tuple.mjs +21 -1
- package/node_modules/typebox/build/value/parse/parse.mjs +10 -4
- package/node_modules/typebox/package.json +1 -1
- package/node_modules/typebox/readme.md +3 -1
- package/package.json +5 -5
- package/dist/runtime-api.js +0 -3
- /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 (
|
|
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 (
|
|
507
|
+
if (normalizedName === 'subject') {
|
|
506
508
|
mailtoComponent.subject = decodeHex(value)
|
|
507
509
|
continue
|
|
508
510
|
}
|
|
509
|
-
if (
|
|
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[
|
|
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
|
-
|
|
574
|
-
|
|
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 +=
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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',
|
package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/mailto-authority-security.test.js
ADDED
|
@@ -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
|
+
})
|
package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/mailto-field-name-security.test.js
ADDED
|
@@ -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
|
+
})
|