From 35a23acaa6d59e532f066189be00f9f44e8fe374 Mon Sep 17 00:00:00 2001 From: CR0CKER <6056387+CR0CKER@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:13:12 +0200 Subject: [PATCH] fix(security): neutralize {{macro}} markup in untrusted calendar text (audit M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event summary, description, location, and attendee names come from an external iCal feed. An event the user did not author (a meeting invite, a shared or subscribed calendar) could carry a {{query}}/{{renderer}} in its title or description and have it execute in the user's graph when rendered. Add sanitizeForBlock (parsing.ts): inserts a zero-width space inside each {{ / }} token so it can't be parsed as a macro, visible text unchanged. Applied to attendee name/email in formatParticipants and to summary/description/location in insertJournalBlocks. Scoped to macros only by user preference: page refs [[...]], block refs ((...)), and #tags are left intact — they are inert and users legitimately put them in their own event titles/descriptions and want them to link. Tests: 43 pass; typecheck 0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019cVCdCtNzUJmy1yen3JyBk --- CHANGELOG.md | 15 ++++++++++++++- index.ts | 11 +++++++---- parsing.test.ts | 32 ++++++++++++++++++++++++++++++++ parsing.ts | 26 ++++++++++++++++++++++++-- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d94ba85d..a7d85c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -Last updated: 2026-07-20 01:03 PM CDT +Last updated: 2026-07-20 01:21 PM CDT All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **Neutralize `{{macro}}` markup in untrusted calendar text** (audit finding + **M1**). Event summary, description, location, and attendee names come from an + external feed; an event the user didn't author (a meeting invite, a shared or + subscribed calendar) could carry a `{{query}}` / `{{renderer}}` in its title or + description and have it *execute* in the user's graph when rendered. A new + `sanitizeForBlock` helper inserts a zero-width space inside each `{{`/`}}` token + (visible text unchanged, no longer parsed as a macro) at every render site. + Page refs `[[...]]`, block refs `((...))`, and `#tags` are deliberately left + intact — they are inert and users legitimately put them in their own event + titles/descriptions and want them to link. Covered by tests. + ### Added - **Recurrence engine tests** (audit finding **H2**, part 2): `recurrence.test.ts` diff --git a/index.ts b/index.ts index 3692f662..01e351cc 100644 --- a/index.ts +++ b/index.ts @@ -6,7 +6,7 @@ import { getDateForPageWithoutBrackets, } from "logseq-dateutils"; import moment from "moment-timezone"; -import { formatParticipants, templateFormatter } from "./parsing"; +import { formatParticipants, templateFormatter, sanitizeForBlock } from "./parsing"; import { parseEvents, ParsedCalendar } from "./recurrence"; let mainBlockUUID = "" @@ -261,7 +261,10 @@ async function insertJournalBlocks( const eventsToInsert = []; for (const dataKey in data) { try { - let description = data[dataKey]["description"]; //Parsing result from rawParser into usable data for templateFormatter + // Neutralize executable {{macro}}/{{query}} markup in untrusted calendar + // text before rendering (audit M1). Page refs [[..]], block refs ((..)) and + // #tags are left intact on purpose so users' own event titles still link. + let description = sanitizeForBlock(data[dataKey]["description"]); //Parsing result from rawParser into usable data for templateFormatter let formattedStart = new Date(data[dataKey]["start"]); let startDate = getDateForPageWithoutBrackets( formattedStart, @@ -269,9 +272,9 @@ async function insertJournalBlocks( ); let startTime = await formatTime(formattedStart); let endTime = await formatTime(data[dataKey]["end"]); - let location = data[dataKey]["location"]; + let location = sanitizeForBlock(data[dataKey]["location"]); let summary; - summary = data[dataKey]["summary"]; + summary = sanitizeForBlock(data[dataKey]["summary"]); // } // Compute participant lists by RSVP status (declined excluded, self excluded) const userEmail = logseq.settings?.userEmail; diff --git a/parsing.test.ts b/parsing.test.ts index 0d29a61b..c09c288a 100644 --- a/parsing.test.ts +++ b/parsing.test.ts @@ -16,6 +16,7 @@ import { isCancelledEvent, parseLocation, templateFormatter, + sanitizeForBlock, ICalAttendee, EventLike, } from "./parsing"; @@ -79,6 +80,37 @@ describe("formatParticipants", () => { it("normalizes a single (non-array) attendee", () => { expect(formatParticipants({ attendee: attendee("mailto:a@x.com", "Ada", "ACCEPTED") }, ["ACCEPTED"], undefined, true)).toBe("[[Ada]]"); }); + + it("neutralizes a {{macro}} in a hostile attendee name but leaves the link markup (M1)", () => { + const e = evt([attendee("mailto:a@x.com", "Ada {{query (and (task TODO))}}", "ACCEPTED")]); + const out = formatParticipants(e, ["ACCEPTED"], undefined, true); + expect(out.startsWith("[[")).toBe(true); + // The executable macro is neutralized (no raw {{ or }} survives)... + expect(out).not.toMatch(/\{\{|\}\}/); + // ...but page-ref brackets are intentionally preserved (users use them). + expect(out).toContain("[["); + }); +}); + +describe("sanitizeForBlock", () => { + it("neutralizes {{ }} macros/queries", () => { + expect(sanitizeForBlock("{{query (and x)}}")).not.toMatch(/\{\{|\}\}/); + }); + it("leaves page refs, block refs, and tags intact (user preference)", () => { + expect(sanitizeForBlock("[[Project X]]")).toBe("[[Project X]]"); + expect(sanitizeForBlock("((block-ref))")).toBe("((block-ref))"); + expect(sanitizeForBlock("#standup notes")).toBe("#standup notes"); + }); + it("leaves plain text unchanged", () => { + expect(sanitizeForBlock("Sprint planning in Room 4B")).toBe("Sprint planning in Room 4B"); + }); + it("keeps the visible text of a macro (only zero-width spaces are inserted)", () => { + // Stripping the zero-width spaces restores the original. + expect(sanitizeForBlock("run {{query x}} now").replace(/\u200B/g, "")).toBe("run {{query x}} now"); + }); + it("passes empty string through unchanged", () => { + expect(sanitizeForBlock("")).toBe(""); + }); }); describe("isCancelledEvent", () => { diff --git a/parsing.ts b/parsing.ts index ed42b133..e981bf9d 100644 --- a/parsing.ts +++ b/parsing.ts @@ -22,6 +22,26 @@ export interface EventLike { start?: Date | string | number; } +/** + * Neutralize only *executable* Logseq markup \u2014 `{{macro}}` / `{{query}}` / + * `{{renderer}}` \u2014 in untrusted calendar text (event summary, location, + * description, attendee names), so an event the user didn't author (a meeting + * invite, a shared/subscribed calendar) can't run a macro in their graph (audit + * finding M1, scoped by user preference). + * + * Page refs `[[...]]`, block refs `((...))`, and `#tags` are deliberately left + * intact and render as written \u2014 users legitimately put these in their own event + * titles/descriptions and want them to link. Those are inert (they only link), so + * only the `{{ }}` macro form is blocked. A zero-width space is inserted inside + * each `{{`/`}}` token: the visible text is unchanged but Logseq no longer parses + * it as a macro. Falsy/non-string input is returned unchanged. + */ +export function sanitizeForBlock(text: string): string { + if (!text) return text; + const zw = "\u200B"; // zero-width space + return text.replaceAll("{{", `{${zw}{`).replaceAll("}}", `}${zw}}`); +} + /** * Filter out events with a missing/invalid start, then sort ascending by * absolute (UTC) start time — which matches the displayed local order. @@ -118,9 +138,11 @@ export function formatParticipants( if (normalizedUserEmail && email === normalizedUserEmail) continue; // exclude self const name = getAttendeeName(a); if (name) { - entries.push(`[[${name}]]`); + // Sanitize the (untrusted) name before wrapping it in the plugin's own + // [[...]] so a crafted CN can't break out of the link or inject a macro. + entries.push(`[[${sanitizeForBlock(name)}]]`); } else if (emailFallback && a?.val) { - entries.push(a.val.replace(/^mailto:/i, "")); + entries.push(sanitizeForBlock(a.val.replace(/^mailto:/i, ""))); } } return entries.join(", ");