@blamejs/core 0.9.49 → 0.10.1
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/CHANGELOG.md +951 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/safe-ical.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.safeIcal
|
|
4
|
+
* @nav Parsers
|
|
5
|
+
* @title Safe iCalendar
|
|
6
|
+
* @order 125
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Bounded RFC 5545 iCalendar parser. Walks the content-line grammar
|
|
10
|
+
* (`BEGIN:VCALENDAR` ... `END:VCALENDAR`) into a JSON AST that the
|
|
11
|
+
* mail / DAV / scheduling stacks can reason about without giving an
|
|
12
|
+
* attacker access to the parser's recursion / expansion machinery.
|
|
13
|
+
*
|
|
14
|
+
* Substrate for the calendar storage protocol (`b.mail.dav`),
|
|
15
|
+
* delivery-time iTIP processing, and the scheduling primitives that
|
|
16
|
+
* compose against ical bytes.
|
|
17
|
+
*
|
|
18
|
+
* Defends `CVE-2024-39687` (ical4j RRULE recursion / "Outlook
|
|
19
|
+
* calendar bomb" — a hostile RRULE with unbounded COUNT and
|
|
20
|
+
* recursive BYxxx expansion can pin a CalDAV server's CPU at 100%
|
|
21
|
+
* until the request times out). Caps:
|
|
22
|
+
*
|
|
23
|
+
* - Total bytes (256 KiB strict / 1 MiB balanced / 4 MiB
|
|
24
|
+
* permissive) — refused before parsing begins.
|
|
25
|
+
* - BEGIN/END nesting depth (16 / 32 / 64) — refused when a
|
|
26
|
+
* hostile blob nests VALARM-in-VEVENT-in-VEVENT-in-… past the
|
|
27
|
+
* cap.
|
|
28
|
+
* - Total content lines (16k / 65k / 262k) — refused after
|
|
29
|
+
* line-unfolding when a hostile blob ships gigabytes of
|
|
30
|
+
* single-property repetitions.
|
|
31
|
+
* - Per-line bytes after unfolding (8 KiB strict / 32 KiB balanced
|
|
32
|
+
* / 128 KiB permissive). RFC 5545 §3.1 recommends 75 octets per
|
|
33
|
+
* unfolded segment but folding is unbounded.
|
|
34
|
+
* - RRULE COUNT cap (10000 entries) — refused regardless of
|
|
35
|
+
* profile. The recurrence expander never materializes more
|
|
36
|
+
* instances than this cap.
|
|
37
|
+
* - RRULE BYDAY / BYMONTH / BYMONTHDAY / BYHOUR / BYMINUTE /
|
|
38
|
+
* BYSECOND / BYSETPOS / BYWEEKNO / BYYEARDAY list-length cap
|
|
39
|
+
* (24 entries) — refused regardless of profile. CVE-2024-39687
|
|
40
|
+
* achieves expansion blow-up by stacking long BYxxx lists.
|
|
41
|
+
*
|
|
42
|
+
* Header-injection / control-char defense: refuses NUL, C0 control
|
|
43
|
+
* bytes (other than TAB inside QUOTED-PRINTABLE-shaped values), and
|
|
44
|
+
* DEL (0x7F) inside property values. Defends against downstream
|
|
45
|
+
* consumers that splice ical fields into HTTP / SMTP / log headers.
|
|
46
|
+
*
|
|
47
|
+
* Property allowlist: every property name in the AST must either
|
|
48
|
+
* appear in the RFC 5545 / 5546 / 7986 property registry or carry
|
|
49
|
+
* the `X-` experimental prefix per §3.8.8.2. Unknown bare property
|
|
50
|
+
* names are refused regardless of profile — that path has been a
|
|
51
|
+
* reliable detection bypass on legacy parsers.
|
|
52
|
+
*
|
|
53
|
+
* The parser is purely functional — no I/O, no async, no side
|
|
54
|
+
* effects. Operators run it inside `b.workerPool` workers for any
|
|
55
|
+
* PUT body above an operator-chosen byte threshold.
|
|
56
|
+
*
|
|
57
|
+
* Explicit non-goals (deferred — operator escape hatch noted):
|
|
58
|
+
*
|
|
59
|
+
* - **JSCalendar (RFC 8984)** — JSON-native calendar grammar. The
|
|
60
|
+
* parser ships the AST in a JSON-shaped tree, but full
|
|
61
|
+
* JSCalendar conversion (timezone resolution, recurrence
|
|
62
|
+
* expansion to ISO 8601 instances, byday-string → enum) lights
|
|
63
|
+
* up when an operator requests it. Today's AST gives operators
|
|
64
|
+
* the raw `RRULE` / `RDATE` / `EXDATE` strings.
|
|
65
|
+
* - **VTIMEZONE inline composition** — operators reference IANA
|
|
66
|
+
* tzdb names via `TZID` and let the consuming layer resolve.
|
|
67
|
+
* Inline VTIMEZONE blocks parse (their components are walked
|
|
68
|
+
* into the AST) but the parser does not synthesize a missing
|
|
69
|
+
* VTIMEZONE from `TZID=…`.
|
|
70
|
+
* - **iTIP / iMIP (RFC 5546 / 6047)** — the SCHEDULE-AGENT /
|
|
71
|
+
* METHOD vocabulary parses fine; the cross-mail delivery hook
|
|
72
|
+
* that turns an iTIP message into a calendar update lives in
|
|
73
|
+
* the mail-server slice.
|
|
74
|
+
*
|
|
75
|
+
* @card
|
|
76
|
+
* Bounded RFC 5545 iCalendar parser — caps total bytes, nesting
|
|
77
|
+
* depth, RRULE COUNT and BYxxx list-lengths; refuses NUL / C0 / DEL
|
|
78
|
+
* inside property values; allowlists property names; defends
|
|
79
|
+
* CVE-2024-39687 (ical4j RRULE recursion / Outlook calendar-bomb).
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
var C = require("./constants");
|
|
83
|
+
var { defineClass } = require("./framework-error");
|
|
84
|
+
|
|
85
|
+
var SafeIcalError = defineClass("SafeIcalError", { alwaysPermanent: true });
|
|
86
|
+
|
|
87
|
+
// RRULE caps are enforced regardless of profile — CVE-2024-39687 has
|
|
88
|
+
// no safe permissive posture.
|
|
89
|
+
var RRULE_MAX_COUNT = 10000; // allow:raw-byte-literal — RFC 5545 §3.3.10 recurrence-count cap
|
|
90
|
+
var RRULE_MAX_BY_ENTRIES = 24; // allow:raw-byte-literal — BYxxx list-length cap
|
|
91
|
+
|
|
92
|
+
var PROFILES = Object.freeze({
|
|
93
|
+
strict: Object.freeze({
|
|
94
|
+
maxBytes: C.BYTES.kib(256),
|
|
95
|
+
maxLineBytes: C.BYTES.kib(8),
|
|
96
|
+
maxLines: 16384, // allow:raw-byte-literal — line count cap, not byte size
|
|
97
|
+
maxNestingDepth: 16, // allow:raw-byte-literal — nesting depth cap, not bytes
|
|
98
|
+
maxComponents: 4096, // allow:raw-byte-literal — total component count cap, not bytes
|
|
99
|
+
maxPropertiesPerComponent: 256, // allow:raw-byte-literal — per-component prop count, not bytes
|
|
100
|
+
}),
|
|
101
|
+
balanced: Object.freeze({
|
|
102
|
+
maxBytes: C.BYTES.mib(1),
|
|
103
|
+
maxLineBytes: C.BYTES.kib(32),
|
|
104
|
+
maxLines: 65536, // allow:raw-byte-literal — line count cap, not byte size
|
|
105
|
+
maxNestingDepth: 32, // allow:raw-byte-literal — nesting depth cap, not bytes
|
|
106
|
+
maxComponents: 16384, // allow:raw-byte-literal — total component count cap, not bytes
|
|
107
|
+
maxPropertiesPerComponent: 1024, // allow:raw-byte-literal — per-component prop count, not bytes
|
|
108
|
+
}),
|
|
109
|
+
permissive: Object.freeze({
|
|
110
|
+
maxBytes: C.BYTES.mib(4),
|
|
111
|
+
maxLineBytes: C.BYTES.kib(128),
|
|
112
|
+
maxLines: 262144, // allow:raw-byte-literal — line count cap, not byte size
|
|
113
|
+
maxNestingDepth: 64, // allow:raw-byte-literal — nesting depth cap, not bytes
|
|
114
|
+
maxComponents: 65536, // allow:raw-byte-literal — total component count cap, not bytes
|
|
115
|
+
maxPropertiesPerComponent: 4096, // allow:raw-byte-literal — per-component prop count, not bytes
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
120
|
+
hipaa: "strict",
|
|
121
|
+
"pci-dss": "strict",
|
|
122
|
+
gdpr: "strict",
|
|
123
|
+
soc2: "strict",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Property-name allowlist per RFC 5545 §8.7 (Property Registry) +
|
|
127
|
+
// RFC 5546 §4.3 (iTIP additions) + RFC 7986 §5 (new calendar
|
|
128
|
+
// properties). Unknown bare names are refused; `X-` experimental
|
|
129
|
+
// names are admitted regardless. The allowlist is conservative — when
|
|
130
|
+
// a missing property surfaces in production, the operator extends via
|
|
131
|
+
// `opts.extraProperties`.
|
|
132
|
+
var KNOWN_PROPERTIES = Object.freeze({
|
|
133
|
+
// Calendar-level (RFC 5545 §3.7)
|
|
134
|
+
CALSCALE: true, METHOD: true, PRODID: true, VERSION: true,
|
|
135
|
+
// Descriptive (RFC 5545 §3.8.1)
|
|
136
|
+
ATTACH: true, CATEGORIES: true, CLASS: true, COMMENT: true,
|
|
137
|
+
DESCRIPTION: true, GEO: true, LOCATION: true, "PERCENT-COMPLETE": true,
|
|
138
|
+
PRIORITY: true, RESOURCES: true, STATUS: true, SUMMARY: true,
|
|
139
|
+
// Date / time (RFC 5545 §3.8.2)
|
|
140
|
+
COMPLETED: true, DTEND: true, DUE: true, DTSTART: true, DURATION: true,
|
|
141
|
+
FREEBUSY: true, TRANSP: true,
|
|
142
|
+
// Time zone (RFC 5545 §3.8.3)
|
|
143
|
+
TZID: true, TZNAME: true, TZOFFSETFROM: true, TZOFFSETTO: true, TZURL: true,
|
|
144
|
+
// Relationship (RFC 5545 §3.8.4)
|
|
145
|
+
ATTENDEE: true, CONTACT: true, ORGANIZER: true, "RECURRENCE-ID": true,
|
|
146
|
+
"RELATED-TO": true, URL: true, UID: true,
|
|
147
|
+
// Recurrence (RFC 5545 §3.8.5)
|
|
148
|
+
EXDATE: true, EXRULE: true, RDATE: true, RRULE: true,
|
|
149
|
+
// Alarm (RFC 5545 §3.8.6)
|
|
150
|
+
ACTION: true, REPEAT: true, TRIGGER: true,
|
|
151
|
+
// Change management (RFC 5545 §3.8.7)
|
|
152
|
+
CREATED: true, "DTSTAMP": true, "LAST-MODIFIED": true, SEQUENCE: true,
|
|
153
|
+
// Miscellaneous (RFC 5545 §3.8.8)
|
|
154
|
+
"REQUEST-STATUS": true,
|
|
155
|
+
// RFC 7986 — new calendar properties
|
|
156
|
+
NAME: true, "REFRESH-INTERVAL": true, SOURCE: true, COLOR: true, IMAGE: true,
|
|
157
|
+
CONFERENCE: true,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Component-name allowlist per RFC 5545 §3.6 + §3.6.7 (VFREEBUSY) +
|
|
161
|
+
// §3.6.4 (VJOURNAL) + RFC 7953 (VAVAILABILITY).
|
|
162
|
+
var KNOWN_COMPONENTS = Object.freeze({
|
|
163
|
+
VCALENDAR: true,
|
|
164
|
+
VEVENT: true, VTODO: true, VJOURNAL: true, VFREEBUSY: true,
|
|
165
|
+
VTIMEZONE: true, STANDARD: true, DAYLIGHT: true,
|
|
166
|
+
VALARM: true,
|
|
167
|
+
VAVAILABILITY: true, AVAILABLE: true,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @primitive b.safeIcal.parse
|
|
172
|
+
* @signature b.safeIcal.parse(text, opts?)
|
|
173
|
+
* @since 0.9.81
|
|
174
|
+
* @status stable
|
|
175
|
+
* @related b.safeVcard.parse, b.mail.dav.create
|
|
176
|
+
*
|
|
177
|
+
* Parse RFC 5545 iCalendar text into a JSON AST. Returns
|
|
178
|
+
* `{ vcalendar: { properties: {...}, vevent: [...], vtodo: [...],
|
|
179
|
+
* vjournal: [...], vfreebusy: [...], vtimezone: [...] } }`.
|
|
180
|
+
*
|
|
181
|
+
* Throws `SafeIcalError` with codes:
|
|
182
|
+
* `safe-ical/oversize-bytes` /
|
|
183
|
+
* `oversize-line-bytes` / `oversize-lines` / `oversize-nesting` /
|
|
184
|
+
* `oversize-components` / `oversize-properties-per-component` /
|
|
185
|
+
* `oversize-rrule-count` / `oversize-rrule-by` /
|
|
186
|
+
* `missing-vcalendar` / `unterminated-component` /
|
|
187
|
+
* `unknown-property` / `unknown-component` /
|
|
188
|
+
* `control-char-in-value` / `bad-line` / `bad-input` / `bad-opt`.
|
|
189
|
+
*
|
|
190
|
+
* @opts
|
|
191
|
+
* profile: "strict" | "balanced" | "permissive", // default strict
|
|
192
|
+
* compliancePosture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // → strict
|
|
193
|
+
* extraProperties: string[], // operator-extended allowlist
|
|
194
|
+
* extraComponents: string[], // operator-extended allowlist
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* var ast = b.safeIcal.parse(
|
|
198
|
+
* "BEGIN:VCALENDAR\r\n" +
|
|
199
|
+
* "VERSION:2.0\r\n" +
|
|
200
|
+
* "PRODID:-//Example//1.0//EN\r\n" +
|
|
201
|
+
* "BEGIN:VEVENT\r\n" +
|
|
202
|
+
* "UID:abc@example.com\r\n" +
|
|
203
|
+
* "DTSTAMP:20260101T120000Z\r\n" +
|
|
204
|
+
* "DTSTART:20260101T130000Z\r\n" +
|
|
205
|
+
* "SUMMARY:Team meeting\r\n" +
|
|
206
|
+
* "END:VEVENT\r\n" +
|
|
207
|
+
* "END:VCALENDAR\r\n"
|
|
208
|
+
* );
|
|
209
|
+
* ast.vcalendar.vevent[0].properties.SUMMARY[0].value; // → "Team meeting"
|
|
210
|
+
*/
|
|
211
|
+
function parse(text, opts) {
|
|
212
|
+
opts = opts || {};
|
|
213
|
+
var caps = _resolveCaps(opts);
|
|
214
|
+
var extraProps = _toSet(opts.extraProperties);
|
|
215
|
+
var extraComps = _toSet(opts.extraComponents);
|
|
216
|
+
|
|
217
|
+
if (typeof text !== "string" && !Buffer.isBuffer(text)) {
|
|
218
|
+
throw new SafeIcalError("safe-ical/bad-input",
|
|
219
|
+
"safeIcal.parse: input must be string or Buffer (got " + typeof text + ")");
|
|
220
|
+
}
|
|
221
|
+
var s = typeof text === "string" ? text : text.toString("utf8");
|
|
222
|
+
var byteLen = Buffer.byteLength(s, "utf8");
|
|
223
|
+
if (byteLen > caps.maxBytes) {
|
|
224
|
+
throw new SafeIcalError("safe-ical/oversize-bytes",
|
|
225
|
+
"safeIcal.parse: input " + byteLen + " bytes exceeds maxBytes=" + caps.maxBytes +
|
|
226
|
+
" (CVE-2024-39687-class defense)");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
var lines = _unfold(s, caps);
|
|
230
|
+
|
|
231
|
+
// Top-level walk — must open with BEGIN:VCALENDAR. RFC 5545 §3.4
|
|
232
|
+
// permits multiple VCALENDAR objects in a stream; we accept the
|
|
233
|
+
// first one and require the rest to round-trip cleanly.
|
|
234
|
+
var ctx = {
|
|
235
|
+
caps: caps,
|
|
236
|
+
extraProps: extraProps,
|
|
237
|
+
extraComps: extraComps,
|
|
238
|
+
componentCount: 0,
|
|
239
|
+
};
|
|
240
|
+
var idx = 0;
|
|
241
|
+
while (idx < lines.length && lines[idx].name !== "BEGIN") idx++;
|
|
242
|
+
if (idx >= lines.length) {
|
|
243
|
+
throw new SafeIcalError("safe-ical/missing-vcalendar",
|
|
244
|
+
"safeIcal.parse: no BEGIN:VCALENDAR line found");
|
|
245
|
+
}
|
|
246
|
+
if (lines[idx].value !== "VCALENDAR") {
|
|
247
|
+
throw new SafeIcalError("safe-ical/missing-vcalendar",
|
|
248
|
+
"safeIcal.parse: first BEGIN line must be VCALENDAR (got '" + lines[idx].value + "')");
|
|
249
|
+
}
|
|
250
|
+
var consumed = _parseComponent(lines, idx, ctx, 0);
|
|
251
|
+
var vcal = consumed.component;
|
|
252
|
+
// RFC 5545 §3.4 — a stream may carry multiple VCALENDAR objects.
|
|
253
|
+
// Walk the remainder so trailing objects are validated under the
|
|
254
|
+
// same caps + control-char + property allowlist (Codex P2 — without
|
|
255
|
+
// this, CalDAV ingest can pass validation on the first object while
|
|
256
|
+
// trailing malformed objects ride through untouched).
|
|
257
|
+
var vcalendars = [_shapeVcalendar(vcal)];
|
|
258
|
+
var cursor = consumed.nextIdx;
|
|
259
|
+
while (cursor < lines.length) {
|
|
260
|
+
while (cursor < lines.length && lines[cursor].name !== "BEGIN") cursor++;
|
|
261
|
+
if (cursor >= lines.length) break;
|
|
262
|
+
if (lines[cursor].value !== "VCALENDAR") {
|
|
263
|
+
throw new SafeIcalError("safe-ical/missing-vcalendar",
|
|
264
|
+
"safeIcal.parse: BEGIN at line " + (lines[cursor].lineNo || cursor) +
|
|
265
|
+
" must be VCALENDAR (got '" + lines[cursor].value + "')");
|
|
266
|
+
}
|
|
267
|
+
var more = _parseComponent(lines, cursor, ctx, 0);
|
|
268
|
+
vcalendars.push(_shapeVcalendar(more.component));
|
|
269
|
+
cursor = more.nextIdx;
|
|
270
|
+
}
|
|
271
|
+
return vcalendars.length === 1
|
|
272
|
+
? { vcalendar: vcalendars[0] }
|
|
273
|
+
: { vcalendar: vcalendars[0], vcalendars: vcalendars };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @primitive b.safeIcal.compliancePosture
|
|
278
|
+
* @signature b.safeIcal.compliancePosture(name)
|
|
279
|
+
* @since 0.9.81
|
|
280
|
+
* @status stable
|
|
281
|
+
* @related b.safeIcal.parse
|
|
282
|
+
*
|
|
283
|
+
* Map a compliance-posture name to its profile. Returns the profile
|
|
284
|
+
* string for a known posture, `null` for unknown names.
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* b.safeIcal.compliancePosture("hipaa"); // → "strict"
|
|
288
|
+
* b.safeIcal.compliancePosture("loose"); // → null
|
|
289
|
+
*/
|
|
290
|
+
function compliancePosture(name) {
|
|
291
|
+
return COMPLIANCE_POSTURES[name] || null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- Profile / opt resolution ----
|
|
295
|
+
|
|
296
|
+
function _resolveCaps(opts) {
|
|
297
|
+
var name = "strict";
|
|
298
|
+
if (typeof opts.profile === "string") {
|
|
299
|
+
name = opts.profile;
|
|
300
|
+
} else if (typeof opts.compliancePosture === "string") {
|
|
301
|
+
name = COMPLIANCE_POSTURES[opts.compliancePosture] || "strict";
|
|
302
|
+
}
|
|
303
|
+
var caps = PROFILES[name];
|
|
304
|
+
if (!caps) {
|
|
305
|
+
throw new SafeIcalError("safe-ical/bad-opt",
|
|
306
|
+
"safeIcal.parse: unknown profile '" + name +
|
|
307
|
+
"' (expected strict|balanced|permissive)");
|
|
308
|
+
}
|
|
309
|
+
return caps;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function _toSet(arr) {
|
|
313
|
+
var set = Object.create(null);
|
|
314
|
+
if (!Array.isArray(arr)) return set;
|
|
315
|
+
for (var i = 0; i < arr.length; i++) {
|
|
316
|
+
if (typeof arr[i] === "string") set[arr[i].toUpperCase()] = true;
|
|
317
|
+
}
|
|
318
|
+
return set;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- Line unfolding (RFC 5545 §3.1) ----
|
|
322
|
+
//
|
|
323
|
+
// "Lines of text SHOULD NOT be longer than 75 octets, excluding the
|
|
324
|
+
// line break. Long content lines SHOULD be split into a multiple
|
|
325
|
+
// line representations using a line 'folding' technique. That is, a
|
|
326
|
+
// long line can be split between any two characters by inserting a
|
|
327
|
+
// CRLF immediately followed by a single linear white-space character
|
|
328
|
+
// (i.e., SPACE or HTAB)."
|
|
329
|
+
//
|
|
330
|
+
// We unfold by joining a continuation line (one starting with SPACE
|
|
331
|
+
// or HTAB) onto the prior line after stripping the leading whitespace
|
|
332
|
+
// character.
|
|
333
|
+
|
|
334
|
+
function _unfold(s, caps) {
|
|
335
|
+
// Normalize line endings — RFC 5545 specifies CRLF but real-world
|
|
336
|
+
// ical bytes also use bare LF (and occasionally bare CR on legacy
|
|
337
|
+
// emitters). Treat \r\n / \n / \r identically.
|
|
338
|
+
var raw = s.replace(/\r\n?|\n/g, "\n").split("\n");
|
|
339
|
+
var unfolded = [];
|
|
340
|
+
for (var i = 0; i < raw.length; i++) {
|
|
341
|
+
var line = raw[i];
|
|
342
|
+
if (line.length === 0) {
|
|
343
|
+
// Blank lines are tolerated between the closing END line and
|
|
344
|
+
// EOF (some emitters add a trailing newline); skip them.
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
var firstChar = line.charCodeAt(0);
|
|
348
|
+
if (firstChar === 0x20 || firstChar === 0x09) { // allow:raw-byte-literal — SPACE / HTAB are RFC 5545 §3.1 fold markers
|
|
349
|
+
if (unfolded.length === 0) {
|
|
350
|
+
throw new SafeIcalError("safe-ical/bad-line",
|
|
351
|
+
"safeIcal.parse: continuation line before any content line");
|
|
352
|
+
}
|
|
353
|
+
unfolded[unfolded.length - 1] += line.slice(1);
|
|
354
|
+
} else {
|
|
355
|
+
unfolded.push(line);
|
|
356
|
+
}
|
|
357
|
+
if (unfolded.length > caps.maxLines) {
|
|
358
|
+
throw new SafeIcalError("safe-ical/oversize-lines",
|
|
359
|
+
"safeIcal.parse: line count exceeds maxLines=" + caps.maxLines);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
var parsed = [];
|
|
364
|
+
for (var j = 0; j < unfolded.length; j++) {
|
|
365
|
+
var u = unfolded[j];
|
|
366
|
+
if (Buffer.byteLength(u, "utf8") > caps.maxLineBytes) {
|
|
367
|
+
throw new SafeIcalError("safe-ical/oversize-line-bytes",
|
|
368
|
+
"safeIcal.parse: unfolded line " + (j + 1) + " exceeds maxLineBytes=" + caps.maxLineBytes);
|
|
369
|
+
}
|
|
370
|
+
parsed.push(_parseContentLine(u));
|
|
371
|
+
}
|
|
372
|
+
return parsed;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---- Content-line parser (RFC 5545 §3.1) ----
|
|
376
|
+
//
|
|
377
|
+
// `contentline = name *(";" param) ":" value CRLF`
|
|
378
|
+
// `name = iana-token / x-name`
|
|
379
|
+
// `param = param-name "=" param-value *("," param-value)`
|
|
380
|
+
|
|
381
|
+
function _parseContentLine(line) {
|
|
382
|
+
// Split off the value at the first un-quoted `:`.
|
|
383
|
+
var colonIdx = _findUnquotedColon(line);
|
|
384
|
+
if (colonIdx < 0) {
|
|
385
|
+
throw new SafeIcalError("safe-ical/bad-line",
|
|
386
|
+
"safeIcal.parse: content line missing ':' separator: " +
|
|
387
|
+
_preview(line));
|
|
388
|
+
}
|
|
389
|
+
var head = line.slice(0, colonIdx);
|
|
390
|
+
var value = line.slice(colonIdx + 1);
|
|
391
|
+
|
|
392
|
+
// Refuse NUL, C0 control bytes (other than TAB), and DEL in the
|
|
393
|
+
// value. Header-injection / log-poisoning defense.
|
|
394
|
+
for (var k = 0; k < value.length; k++) {
|
|
395
|
+
var cc = value.charCodeAt(k);
|
|
396
|
+
if ((cc < 0x20 && cc !== 0x09) || cc === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
|
|
397
|
+
throw new SafeIcalError("safe-ical/control-char-in-value",
|
|
398
|
+
"safeIcal.parse: control char 0x" + cc.toString(16) +
|
|
399
|
+
" in property value (header-injection defense)");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Split params off the property name.
|
|
404
|
+
var segs = _splitUnquoted(head, ";");
|
|
405
|
+
var name = segs[0].toUpperCase();
|
|
406
|
+
var params = Object.create(null);
|
|
407
|
+
for (var p = 1; p < segs.length; p++) {
|
|
408
|
+
var seg = segs[p];
|
|
409
|
+
var eq = seg.indexOf("=");
|
|
410
|
+
if (eq < 0) {
|
|
411
|
+
throw new SafeIcalError("safe-ical/bad-line",
|
|
412
|
+
"safeIcal.parse: malformed parameter '" + seg + "'");
|
|
413
|
+
}
|
|
414
|
+
var pname = seg.slice(0, eq).toUpperCase();
|
|
415
|
+
var pvalue = seg.slice(eq + 1);
|
|
416
|
+
if (pname === "__proto__" || pname === "constructor" || pname === "prototype") continue;
|
|
417
|
+
if (params[pname]) {
|
|
418
|
+
params[pname].push(_stripDoubleQuotes(pvalue));
|
|
419
|
+
} else {
|
|
420
|
+
params[pname] = [_stripDoubleQuotes(pvalue)];
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { name: name, params: params, value: value };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function _findUnquotedColon(line) {
|
|
427
|
+
var inQ = false;
|
|
428
|
+
for (var i = 0; i < line.length; i++) {
|
|
429
|
+
var c = line.charCodeAt(i);
|
|
430
|
+
if (c === 0x22) { inQ = !inQ; continue; } // allow:raw-byte-literal — DQUOTE per RFC 5545 §3.1 quoted-string
|
|
431
|
+
if (c === 0x3A && !inQ) return i; // allow:raw-byte-literal — colon separator per RFC 5545 §3.1
|
|
432
|
+
}
|
|
433
|
+
return -1;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function _splitUnquoted(s, sep) {
|
|
437
|
+
var out = [];
|
|
438
|
+
var inQ = false;
|
|
439
|
+
var start = 0;
|
|
440
|
+
for (var i = 0; i < s.length; i++) {
|
|
441
|
+
var c = s.charAt(i);
|
|
442
|
+
if (c === '"') { inQ = !inQ; continue; }
|
|
443
|
+
if (c === sep && !inQ) {
|
|
444
|
+
out.push(s.slice(start, i));
|
|
445
|
+
start = i + 1;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
out.push(s.slice(start));
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function _stripDoubleQuotes(s) {
|
|
453
|
+
if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') {
|
|
454
|
+
return s.slice(1, -1);
|
|
455
|
+
}
|
|
456
|
+
return s;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---- Component parser (RFC 5545 §3.6) ----
|
|
460
|
+
//
|
|
461
|
+
// Each component is bracketed by `BEGIN:<NAME>` and `END:<NAME>`
|
|
462
|
+
// lines. Components nest (VALARM inside VEVENT; STANDARD / DAYLIGHT
|
|
463
|
+
// inside VTIMEZONE; AVAILABLE inside VAVAILABILITY).
|
|
464
|
+
|
|
465
|
+
function _parseComponent(lines, startIdx, ctx, depth) {
|
|
466
|
+
if (depth > ctx.caps.maxNestingDepth) {
|
|
467
|
+
throw new SafeIcalError("safe-ical/oversize-nesting",
|
|
468
|
+
"safeIcal.parse: nesting depth exceeds maxNestingDepth=" +
|
|
469
|
+
ctx.caps.maxNestingDepth + " (CVE-2024-39687-class defense)");
|
|
470
|
+
}
|
|
471
|
+
ctx.componentCount += 1;
|
|
472
|
+
if (ctx.componentCount > ctx.caps.maxComponents) {
|
|
473
|
+
throw new SafeIcalError("safe-ical/oversize-components",
|
|
474
|
+
"safeIcal.parse: component count exceeds maxComponents=" +
|
|
475
|
+
ctx.caps.maxComponents);
|
|
476
|
+
}
|
|
477
|
+
var begin = lines[startIdx];
|
|
478
|
+
if (begin.name !== "BEGIN") {
|
|
479
|
+
throw new SafeIcalError("safe-ical/bad-line",
|
|
480
|
+
"safeIcal.parse: expected BEGIN, got '" + begin.name + "'");
|
|
481
|
+
}
|
|
482
|
+
var compName = begin.value.toUpperCase();
|
|
483
|
+
if (!KNOWN_COMPONENTS[compName] && !ctx.extraComps[compName] &&
|
|
484
|
+
compName.indexOf("X-") !== 0) {
|
|
485
|
+
throw new SafeIcalError("safe-ical/unknown-component",
|
|
486
|
+
"safeIcal.parse: unknown component '" + compName +
|
|
487
|
+
"' (extend via opts.extraComponents or use X- prefix)");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
var properties = Object.create(null);
|
|
491
|
+
var children = [];
|
|
492
|
+
var propertyCount = 0;
|
|
493
|
+
var i = startIdx + 1;
|
|
494
|
+
while (i < lines.length) {
|
|
495
|
+
var ln = lines[i];
|
|
496
|
+
if (ln.name === "BEGIN") {
|
|
497
|
+
var child = _parseComponent(lines, i, ctx, depth + 1);
|
|
498
|
+
children.push(child.component);
|
|
499
|
+
i = child.nextIdx;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (ln.name === "END") {
|
|
503
|
+
if (ln.value.toUpperCase() !== compName) {
|
|
504
|
+
throw new SafeIcalError("safe-ical/unterminated-component",
|
|
505
|
+
"safeIcal.parse: BEGIN:" + compName + " closed by END:" + ln.value);
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
component: { name: compName, properties: properties, children: children },
|
|
509
|
+
nextIdx: i + 1,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
// Validate property name.
|
|
513
|
+
var pn = ln.name;
|
|
514
|
+
if (!KNOWN_PROPERTIES[pn] && !ctx.extraProps[pn] && pn.indexOf("X-") !== 0) {
|
|
515
|
+
throw new SafeIcalError("safe-ical/unknown-property",
|
|
516
|
+
"safeIcal.parse: unknown property '" + pn +
|
|
517
|
+
"' (extend via opts.extraProperties or use X- prefix)");
|
|
518
|
+
}
|
|
519
|
+
// RRULE caps — CVE-2024-39687 defense.
|
|
520
|
+
if (pn === "RRULE" || pn === "EXRULE") {
|
|
521
|
+
_validateRrule(ln.value);
|
|
522
|
+
}
|
|
523
|
+
propertyCount += 1;
|
|
524
|
+
if (propertyCount > ctx.caps.maxPropertiesPerComponent) {
|
|
525
|
+
throw new SafeIcalError("safe-ical/oversize-properties-per-component",
|
|
526
|
+
"safeIcal.parse: property count in " + compName +
|
|
527
|
+
" exceeds maxPropertiesPerComponent=" + ctx.caps.maxPropertiesPerComponent);
|
|
528
|
+
}
|
|
529
|
+
if (pn === "__proto__" || pn === "constructor" || pn === "prototype") {
|
|
530
|
+
i += 1;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!properties[pn]) properties[pn] = [];
|
|
534
|
+
properties[pn].push({ params: ln.params, value: ln.value });
|
|
535
|
+
i += 1;
|
|
536
|
+
}
|
|
537
|
+
throw new SafeIcalError("safe-ical/unterminated-component",
|
|
538
|
+
"safeIcal.parse: BEGIN:" + compName + " never closed (missing END)");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---- RRULE validation (RFC 5545 §3.3.10) ----
|
|
542
|
+
//
|
|
543
|
+
// `recur = "FREQ"=freq *( ";" rulepart )`
|
|
544
|
+
// `rulepart = "UNTIL" / "COUNT" / "INTERVAL" / "BYSECOND" /
|
|
545
|
+
// "BYMINUTE" / "BYHOUR" / "BYDAY" / "BYMONTHDAY" /
|
|
546
|
+
// "BYYEARDAY" / "BYWEEKNO" / "BYMONTH" / "BYSETPOS" /
|
|
547
|
+
// "WKST"`
|
|
548
|
+
|
|
549
|
+
function _validateRrule(value) {
|
|
550
|
+
var parts = value.split(";");
|
|
551
|
+
for (var i = 0; i < parts.length; i++) {
|
|
552
|
+
var kv = parts[i].split("=");
|
|
553
|
+
if (kv.length !== 2) continue;
|
|
554
|
+
var key = kv[0].toUpperCase();
|
|
555
|
+
var val = kv[1];
|
|
556
|
+
if (key === "COUNT") {
|
|
557
|
+
var n = parseInt(val, 10);
|
|
558
|
+
if (!isFinite(n) || n < 0 || n > RRULE_MAX_COUNT) {
|
|
559
|
+
throw new SafeIcalError("safe-ical/oversize-rrule-count",
|
|
560
|
+
"safeIcal.parse: RRULE COUNT=" + val + " exceeds cap=" +
|
|
561
|
+
RRULE_MAX_COUNT + " (CVE-2024-39687 defense)");
|
|
562
|
+
}
|
|
563
|
+
} else if (key === "BYDAY" || key === "BYMONTH" || key === "BYMONTHDAY" ||
|
|
564
|
+
key === "BYHOUR" || key === "BYMINUTE" || key === "BYSECOND" ||
|
|
565
|
+
key === "BYSETPOS" || key === "BYWEEKNO" || key === "BYYEARDAY") {
|
|
566
|
+
var entries = val.split(",");
|
|
567
|
+
if (entries.length > RRULE_MAX_BY_ENTRIES) {
|
|
568
|
+
throw new SafeIcalError("safe-ical/oversize-rrule-by",
|
|
569
|
+
"safeIcal.parse: RRULE " + key + " list length " + entries.length +
|
|
570
|
+
" exceeds cap=" + RRULE_MAX_BY_ENTRIES + " (CVE-2024-39687 defense)");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---- AST shaping (post-parse convenience) ----
|
|
577
|
+
//
|
|
578
|
+
// Re-shapes the raw component tree into a dispatcher-friendly form
|
|
579
|
+
// where each child component kind has its own array:
|
|
580
|
+
// { properties, vevent, vtodo, vjournal, vfreebusy, vtimezone, ... }
|
|
581
|
+
//
|
|
582
|
+
// Unknown child component kinds collect into `.other` so operators
|
|
583
|
+
// can still see them when their allowlist via extraComponents kicks
|
|
584
|
+
// in.
|
|
585
|
+
|
|
586
|
+
function _shapeVcalendar(comp) {
|
|
587
|
+
var out = {
|
|
588
|
+
properties: comp.properties,
|
|
589
|
+
vevent: [],
|
|
590
|
+
vtodo: [],
|
|
591
|
+
vjournal: [],
|
|
592
|
+
vfreebusy: [],
|
|
593
|
+
vtimezone: [],
|
|
594
|
+
vavailability: [],
|
|
595
|
+
other: [],
|
|
596
|
+
};
|
|
597
|
+
for (var i = 0; i < comp.children.length; i++) {
|
|
598
|
+
var ch = comp.children[i];
|
|
599
|
+
var shaped = _shapeComponent(ch);
|
|
600
|
+
switch (ch.name) {
|
|
601
|
+
case "VEVENT": out.vevent.push(shaped); break;
|
|
602
|
+
case "VTODO": out.vtodo.push(shaped); break;
|
|
603
|
+
case "VJOURNAL": out.vjournal.push(shaped); break;
|
|
604
|
+
case "VFREEBUSY": out.vfreebusy.push(shaped); break;
|
|
605
|
+
case "VTIMEZONE": out.vtimezone.push(shaped); break;
|
|
606
|
+
case "VAVAILABILITY": out.vavailability.push(shaped); break;
|
|
607
|
+
default: out.other.push(shaped); break;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function _shapeComponent(comp) {
|
|
614
|
+
var out = { name: comp.name, properties: comp.properties, children: [] };
|
|
615
|
+
for (var i = 0; i < comp.children.length; i++) {
|
|
616
|
+
out.children.push(_shapeComponent(comp.children[i]));
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function _preview(s) {
|
|
622
|
+
if (typeof s !== "string") s = String(s);
|
|
623
|
+
return s.length > 64 ? s.slice(0, 64) + "..." : s; // allow:raw-byte-literal — log-preview length cap
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
module.exports = {
|
|
627
|
+
parse: parse,
|
|
628
|
+
compliancePosture: compliancePosture,
|
|
629
|
+
PROFILES: PROFILES,
|
|
630
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
631
|
+
KNOWN_PROPERTIES: KNOWN_PROPERTIES,
|
|
632
|
+
KNOWN_COMPONENTS: KNOWN_COMPONENTS,
|
|
633
|
+
SafeIcalError: SafeIcalError,
|
|
634
|
+
};
|