Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
# 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [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`
Expand Down
11 changes: 7 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -261,17 +261,20 @@ 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,
preferredDateFormat
);
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;
Expand Down
32 changes: 32 additions & 0 deletions parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isCancelledEvent,
parseLocation,
templateFormatter,
sanitizeForBlock,
ICalAttendee,
EventLike,
} from "./parsing";
Expand Down Expand Up @@ -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", () => {
Expand Down
26 changes: 24 additions & 2 deletions parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(", ");
Expand Down
Loading