Safe iCalendar
Bounded RFC 5545 iCalendar parser. Walks the content-line grammar (BEGIN:VCALENDAR ... END:VCALENDAR) into a JSON AST that the mail / DAV / scheduling stacks can reason about without giving an attacker access to the parser's recursion / expansion machinery.
Substrate for the calendar storage protocol (b.mail.dav), delivery-time iTIP processing, and the scheduling primitives that compose against ical bytes.
Defends the ical4j RRULE-recursion expansion-DoS class ("Outlook calendar bomb" — a hostile RRULE with unbounded COUNT and recursive BYxxx expansion can pin a CalDAV server's CPU at 100% until the request times out). Caps:
- Total bytes (256 KiB strict / 1 MiB balanced / 4 MiB permissive) — refused before parsing begins. - BEGIN/END nesting depth (16 / 32 / 64) — refused when a hostile blob nests VALARM-in-VEVENT-in-VEVENT-in-… past the cap. - Total content lines (16k / 65k / 262k) — refused after line-unfolding when a hostile blob ships gigabytes of single-property repetitions. - Per-line bytes after unfolding (8 KiB strict / 32 KiB balanced / 128 KiB permissive). RFC 5545 §3.1 recommends 75 octets per unfolded segment but folding is unbounded. - RRULE COUNT cap (10000 entries) — refused regardless of profile. The recurrence expander never materializes more instances than this cap. - RRULE BYDAY / BYMONTH / BYMONTHDAY / BYHOUR / BYMINUTE / BYSECOND / BYSETPOS / BYWEEKNO / BYYEARDAY list-length cap (24 entries) — refused regardless of profile. The recursion DoS achieves expansion blow-up by stacking long BYxxx lists.
Header-injection / control-char defense: refuses NUL, C0 control bytes (other than TAB inside QUOTED-PRINTABLE-shaped values), and DEL (0x7F) inside property values. Defends against downstream consumers that splice ical fields into HTTP / SMTP / log headers.
Property allowlist: every property name in the AST must either appear in the RFC 5545 / 5546 / 7986 property registry or carry the X- experimental prefix per §3.8.8.2. Unknown bare property names are refused regardless of profile — that path has been a reliable detection bypass on legacy parsers.
The parser is purely functional — no I/O, no async, no side effects. Operators run it inside b.workerPool workers for any PUT body above an operator-chosen byte threshold.
Explicit non-goals (deferred — operator escape hatch noted):
- **JSCalendar (RFC 8984)** — JSON-native calendar grammar. The parser ships the AST in a JSON-shaped tree, but full JSCalendar conversion (timezone resolution, recurrence expansion to ISO 8601 instances, byday-string → enum) lights up when an operator requests it. Today's AST gives operators the raw RRULE / RDATE / EXDATE strings. - **VTIMEZONE inline composition** — operators reference IANA tzdb names via TZID and let the consuming layer resolve. Inline VTIMEZONE blocks parse (their components are walked into the AST) but the parser does not synthesize a missing VTIMEZONE from TZID=…. - **iTIP / iMIP (RFC 5546 / 6047)** — the SCHEDULE-AGENT / METHOD vocabulary parses fine; the cross-mail delivery hook that turns an iTIP message into a calendar update lives in the mail-server slice.
b.safeIcal.parse(text, opts?) #
{
profile: "strict" | "balanced" | "permissive", // default strict
compliancePosture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // → strict
extraProperties: string[], // operator-extended allowlist
extraComponents: string[], // operator-extended allowlist
}
Parse RFC 5545 iCalendar text into a JSON AST. Returns { vcalendar: { properties: {...}, vevent: [...], vtodo: [...], vjournal: [...], vfreebusy: [...], vtimezone: [...] } }.
Throws SafeIcalError with codes: safe-ical/oversize-bytes / oversize-line-bytes / oversize-lines / oversize-nesting / oversize-components / oversize-properties-per-component / oversize-rrule-count / oversize-rrule-by / missing-vcalendar / unterminated-component / unknown-property / unknown-component / control-char-in-value / bad-line / bad-input / bad-opt.
var ast = b.safeIcal.parse(
"BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//Example//1.0//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:abc@example.com\r\n" +
"DTSTAMP:20260101T120000Z\r\n" +
"DTSTART:20260101T130000Z\r\n" +
"SUMMARY:Team meeting\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
);
ast.vcalendar.vevent[0].properties.SUMMARY[0].value; // → "Team meeting"
b.safeIcal.compliancePosture(name) #
Return the effective profile NAME for a compliance posture, or null for a name this parser does not map. Unlike the content-guard variant this returns the resolved profile string (every line-protocol parser composes gateContract.ALL_STRICT_POSTURES, so "hipaa" / "pci-dss" / "gdpr" / "soc2" all resolve to "strict") and never throws — the parser shape carries no overlay-clone, no buildProfile, and no loadRulePack. Wired by gateContract.defineParser.
b.safeIcal.compliancePosture("hipaa"); // → "strict"
b.safeIcal.compliancePosture("not-a-regime"); // → null
Last updated 2026-08-08T16:39:49.652Z by seeder.