Skip to content

Commit 25592c2

Browse files
MadLlama25claude
andcommitted
fix: harden calendar code from post-merge security review; bump to v1.11.0
Five findings from an adversarial review of the merged PR #53 code: - escapeICalText: normalize bare CR to LF before escaping and strip remaining control characters — a lone \r previously passed through verbatim and could act as a line terminator for downstream parsers (iCal property injection via title/description/location). - normalizeMasterVEventFirst: reorder VEVENT blocks so the master (no RECURRENCE-ID) is first before in-place patching. Component order is not guaranteed by RFC; an exception-first payload previously had its exception patched and skipped the recurring-event guard. - parseICalDateAsUTC: orphaned-exception detection now compares RECURRENCE-IDs and RRULE occurrences in a single UTC frame. With TZ != UTC, naive datetimes were parsed in local time while rrule used naive-as-UTC, flagging valid exceptions as orphans (deleted once the user passed confirmRecurring). - replaceICalProperty: insert new properties before the first sub-component (VALARM) per RFC 5545 ABNF eventprop *alarmc. - Quoted TZID params handled in formatDateTimeProperty and removeOrphanedVTimezones (plus fold-aware reference scanning). 15 new unit tests pin these behaviors. Version synced to 1.11.0 across package.json, manifest.json, src/index.ts, and README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4b29e5e commit 25592c2

7 files changed

Lines changed: 215 additions & 19 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Pin to a tagged release:
114114

115115
```bash
116116
FASTMAIL_API_TOKEN="your_token" \
117-
npx --yes github:MadLlama25/fastmail-mcp@v1.10.0 fastmail-mcp
117+
npx --yes github:MadLlama25/fastmail-mcp@v1.11.0 fastmail-mcp
118118
```
119119

120120
## Install as a Claude Desktop Extension (DXT)

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"dxt_version": "0.1",
33
"name": "fastmail-mcp",
4-
"version": "1.10.0",
4+
"version": "1.11.0",
55
"description": "MCP server for Fastmail API integration",
66
"author": {
77
"name": "Jeremy Gill"

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fastmail-mcp",
3-
"version": "1.10.0",
3+
"version": "1.11.0",
44
"description": "MCP server for Fastmail API integration",
55
"main": "dist/index.js",
66
"type": "module",

src/caldav-client.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
escapeICalText,
1313
unescapeICalText,
1414
validateAndFormatICalDate,
15+
parseICalDateAsUTC,
16+
normalizeMasterVEventFirst,
1517
toICalUTC,
1618
foldICalLine,
1719
detectLineEnding,
@@ -2148,3 +2150,138 @@ describe('Recurring event: no orphans proceeds without confirmRecurring', () =>
21482150
assert.ok(updatedData.includes('DTEND:20260406T120000Z')); // End updated
21492151
});
21502152
});
2153+
2154+
// ---------- v1.11.0 review fixes ----------
2155+
2156+
describe('escapeICalText control-character hardening', () => {
2157+
it('escapes a bare CR as \\n instead of passing it through', () => {
2158+
assert.equal(escapeICalText('Standup\rATTENDEE:mailto:x@example.com'),
2159+
'Standup\\nATTENDEE:mailto:x@example.com');
2160+
});
2161+
2162+
it('still escapes CRLF and LF as \\n', () => {
2163+
assert.equal(escapeICalText('a\r\nb\nc'), 'a\\nb\\nc');
2164+
});
2165+
2166+
it('strips other control characters', () => {
2167+
assert.equal(escapeICalText('a\x00b\x08c\x7Fd'), 'abcd');
2168+
});
2169+
2170+
it('keeps horizontal tabs (legal in iCal TEXT)', () => {
2171+
assert.equal(escapeICalText('a\tb'), 'a\tb');
2172+
});
2173+
});
2174+
2175+
describe('parseICalDateAsUTC', () => {
2176+
it('interprets naive datetimes as UTC regardless of process TZ', () => {
2177+
const d = parseICalDateAsUTC('2026-03-20T09:30:00');
2178+
assert.equal(d.getTime(), Date.UTC(2026, 2, 20, 9, 30, 0));
2179+
});
2180+
2181+
it('handles explicit Z', () => {
2182+
assert.equal(parseICalDateAsUTC('2026-03-20T09:30:00Z').getTime(), Date.UTC(2026, 2, 20, 9, 30, 0));
2183+
});
2184+
2185+
it('handles offsets', () => {
2186+
assert.equal(parseICalDateAsUTC('2026-03-20T10:30:00+01:00').getTime(), Date.UTC(2026, 2, 20, 9, 30, 0));
2187+
});
2188+
2189+
it('handles date-only as UTC midnight', () => {
2190+
assert.equal(parseICalDateAsUTC('2026-03-20').getTime(), Date.UTC(2026, 2, 20));
2191+
});
2192+
});
2193+
2194+
describe('normalizeMasterVEventFirst', () => {
2195+
const exception = 'BEGIN:VEVENT\nUID:u1\nRECURRENCE-ID:20260327T093000Z\nDTSTART:20260327T110000Z\nSUMMARY:Moved instance\nEND:VEVENT';
2196+
const master = 'BEGIN:VEVENT\nUID:u1\nDTSTART:20260320T093000Z\nRRULE:FREQ=WEEKLY\nSUMMARY:Weekly\nEND:VEVENT';
2197+
2198+
it('moves the master VEVENT ahead of an exception-first ordering', () => {
2199+
const data = `BEGIN:VCALENDAR\n${exception}\n${master}\nEND:VCALENDAR`;
2200+
const out = normalizeMasterVEventFirst(data);
2201+
const firstVevent = out.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/)?.[0] || '';
2202+
assert.ok(/^RRULE/m.test(firstVevent), 'master (RRULE, no RECURRENCE-ID) should now be first');
2203+
assert.ok(out.includes('Moved instance'), 'exception must be preserved');
2204+
});
2205+
2206+
it('leaves master-first payloads untouched', () => {
2207+
const data = `BEGIN:VCALENDAR\n${master}\n${exception}\nEND:VCALENDAR`;
2208+
assert.equal(normalizeMasterVEventFirst(data), data);
2209+
});
2210+
2211+
it('leaves single-VEVENT payloads untouched', () => {
2212+
const data = `BEGIN:VCALENDAR\n${master}\nEND:VCALENDAR`;
2213+
assert.equal(normalizeMasterVEventFirst(data), data);
2214+
});
2215+
});
2216+
2217+
describe('replaceICalProperty insert position with VALARM', () => {
2218+
it('inserts a new property before the first sub-component, not after it', () => {
2219+
const data = [
2220+
'BEGIN:VCALENDAR',
2221+
'BEGIN:VEVENT',
2222+
'UID:u1',
2223+
'DTSTART:20260320T093000Z',
2224+
'BEGIN:VALARM',
2225+
'TRIGGER:-PT15M',
2226+
'END:VALARM',
2227+
'END:VEVENT',
2228+
'END:VCALENDAR',
2229+
].join('\n');
2230+
const out = replaceICalProperty(data, 'DESCRIPTION', 'DESCRIPTION:hello');
2231+
const descIdx = out.indexOf('DESCRIPTION:hello');
2232+
const alarmIdx = out.indexOf('BEGIN:VALARM');
2233+
assert.ok(descIdx !== -1 && alarmIdx !== -1);
2234+
assert.ok(descIdx < alarmIdx, 'property must precede VALARM per RFC 5545 ABNF');
2235+
});
2236+
});
2237+
2238+
describe('removeOrphanedVTimezones quoted/folded references', () => {
2239+
it('keeps a VTIMEZONE referenced via a quoted TZID parameter', () => {
2240+
const data = [
2241+
'BEGIN:VCALENDAR',
2242+
'BEGIN:VTIMEZONE',
2243+
'TZID:Custom/Zone',
2244+
'END:VTIMEZONE',
2245+
'BEGIN:VEVENT',
2246+
'UID:u1',
2247+
'DTSTART;TZID="Custom/Zone":20260320T093000',
2248+
'END:VEVENT',
2249+
'END:VCALENDAR',
2250+
].join('\n');
2251+
const out = removeOrphanedVTimezones(data);
2252+
assert.ok(out.includes('BEGIN:VTIMEZONE'), 'referenced VTIMEZONE must not be removed');
2253+
});
2254+
2255+
it('keeps a VTIMEZONE whose reference is split across a folded line', () => {
2256+
const data = [
2257+
'BEGIN:VCALENDAR',
2258+
'BEGIN:VTIMEZONE',
2259+
'TZID:America/Argentina/ComodRivadavia',
2260+
'END:VTIMEZONE',
2261+
'BEGIN:VEVENT',
2262+
'UID:u1',
2263+
'DTSTART;TZID=America/Argentina/Comod',
2264+
' Rivadavia:20260320T093000',
2265+
'END:VEVENT',
2266+
'END:VCALENDAR',
2267+
].join('\n');
2268+
const out = removeOrphanedVTimezones(data);
2269+
assert.ok(out.includes('BEGIN:VTIMEZONE'), 'folded reference must still count');
2270+
});
2271+
2272+
it('still removes a genuinely orphaned VTIMEZONE', () => {
2273+
const data = [
2274+
'BEGIN:VCALENDAR',
2275+
'BEGIN:VTIMEZONE',
2276+
'TZID:Unused/Zone',
2277+
'END:VTIMEZONE',
2278+
'BEGIN:VEVENT',
2279+
'UID:u1',
2280+
'DTSTART:20260320T093000Z',
2281+
'END:VEVENT',
2282+
'END:VCALENDAR',
2283+
].join('\n');
2284+
const out = removeOrphanedVTimezones(data);
2285+
assert.ok(!out.includes('BEGIN:VTIMEZONE'));
2286+
});
2287+
});

src/caldav-client.ts

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,14 @@ export function replaceICalProperty(icalData: string, key: string, newLine: stri
335335
const newLines = newLine !== null ? newLine.split(/\r?\n/) : [];
336336
lines.splice(foundIdx, foundEndIdx - foundIdx, ...newLines);
337337
} else if (newLine !== null) {
338-
// Insert before END:VEVENT
338+
// Insert before the first sub-component (e.g. VALARM) when present —
339+
// RFC 5545 ABNF is `eventprop *alarmc`, so properties must precede alarms.
340+
let insertAt = veventEnd;
341+
for (let i = veventStart + 1; i < veventEnd; i++) {
342+
if (lines[i].trim().startsWith('BEGIN:')) { insertAt = i; break; }
343+
}
339344
const newLines = newLine.split(/\r?\n/);
340-
lines.splice(veventEnd, 0, ...newLines);
345+
lines.splice(insertAt, 0, ...newLines);
341346
}
342347

343348
return lines.join(lineEnding);
@@ -478,13 +483,15 @@ export function removeOrphanedVTimezones(icalData: string): string {
478483
if (lines[i].trim() === 'END:VTIMEZONE') { inTz = false; continue; }
479484
if (!inTz) nonTzLines.push(lines[i]);
480485
}
481-
const nonTzContent = nonTzLines.join('\n');
486+
// Unfold before scanning so a reference split across a folded line isn't
487+
// missed, and check both bare and quoted parameter forms.
488+
const nonTzContent = nonTzLines.join('\n').replace(/\n[ \t]/g, '');
482489

483490
// Check each VTIMEZONE for references
484491
const orphaned = tzBlocks.filter(tz => {
485492
if (!tz.tzid) return false;
486-
// Check for ;TZID=<tzid> references in any property
487-
return !nonTzContent.includes(`;TZID=${tz.tzid}`);
493+
return !nonTzContent.includes(`;TZID=${tz.tzid}`) &&
494+
!nonTzContent.includes(`;TZID="${tz.tzid}"`);
488495
});
489496

490497
// Remove orphaned blocks in reverse order
@@ -535,8 +542,9 @@ export function removeExceptionVEvents(icalData: string, orphanedRecurrenceIds:
535542
if (!block.recurrenceId) return false; // master VEVENT — never remove
536543
const recIdFormatted = formatICalDate(block.recurrenceId);
537544
if (!recIdFormatted) return false;
538-
// Compare as UTC ISO string — handles both date-only and datetime
539-
const recDate = new Date(recIdFormatted);
545+
// Compare in a fixed UTC frame — naive datetimes must not be interpreted
546+
// in the process's local timezone (must match orphan-detection's frame).
547+
const recDate = parseICalDateAsUTC(recIdFormatted);
540548
const recDateStr = recDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
541549
return orphanedDateStrings.includes(recDateStr);
542550
});
@@ -701,10 +709,17 @@ export function unescapeICalText(value: string): string {
701709
*/
702710
export function escapeICalText(value: string): string {
703711
return value
712+
// Normalize CRLF and BARE CR to LF first — a lone \r would otherwise pass
713+
// through untouched and act as a line terminator for downstream parsers,
714+
// reopening the property-injection class the date paths are guarded against.
715+
.replace(/\r\n?/g, '\n')
716+
// Strip remaining control characters (HTAB is legal in iCal TEXT; LF is
717+
// escaped below).
718+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
704719
.replace(/\\/g, '\\\\')
705720
.replace(/;/g, '\\;')
706721
.replace(/,/g, '\\,')
707-
.replace(/\r?\n/g, '\\n');
722+
.replace(/\n/g, '\\n');
708723
}
709724

710725
/**
@@ -857,7 +872,7 @@ function formatDateTimeProperty(
857872
if (originalVevent) {
858873
const rawLines = parseAllICalProperties(originalVevent, propName);
859874
if (rawLines.length > 0) {
860-
const tzMatch = rawLines[0].match(/;TZID=([^;:]+)/);
875+
const tzMatch = rawLines[0].match(/;TZID=("[^"]*"|[^;:]+)/);
861876
if (tzMatch) {
862877
return { line: foldICalLine(`${propName};TZID=${tzMatch[1]}:${icalTime}`, lineEnding), isDateOnly: false };
863878
}
@@ -866,7 +881,7 @@ function formatDateTimeProperty(
866881
if (propName === 'DTEND') {
867882
const startLines = parseAllICalProperties(originalVevent, 'DTSTART');
868883
if (startLines.length > 0) {
869-
const tzMatch = startLines[0].match(/;TZID=([^;:]+)/);
884+
const tzMatch = startLines[0].match(/;TZID=("[^"]*"|[^;:]+)/);
870885
if (tzMatch) {
871886
return { line: foldICalLine(`${propName};TZID=${tzMatch[1]}:${icalTime}`, lineEnding), isDateOnly: false };
872887
}
@@ -920,6 +935,43 @@ function nextDay(dateStr: string): string {
920935
return d.toISOString().slice(0, 10);
921936
}
922937

938+
/**
939+
* Parse an ISO-ish date/datetime string in a fixed UTC frame.
940+
* Naive datetimes ("2026-03-20T09:30:00") are interpreted as UTC — matching
941+
* rrule's naive-as-UTC convention — instead of the process's local timezone,
942+
* which `new Date(...)` would use. Without this, orphaned-exception detection
943+
* compares RECURRENCE-IDs and RRULE occurrences in two different timezone
944+
* frames whenever TZ != UTC, flagging valid exceptions as orphans.
945+
*/
946+
export function parseICalDateAsUTC(iso: string): Date {
947+
if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) return new Date(iso + 'T00:00:00Z');
948+
if (/Z$|[+-]\d{2}:?\d{2}$/.test(iso)) return new Date(iso);
949+
return new Date(iso + 'Z');
950+
}
951+
952+
/**
953+
* Reorder VEVENT blocks so the master (no RECURRENCE-ID) comes first.
954+
* RFC 5545/4791 do not guarantee component ordering — a resource authored by
955+
* a third-party client may list an overridden instance before the master.
956+
* All in-place patch helpers target the first VEVENT, so without this
957+
* normalization an exception-first payload would have its exception patched
958+
* (and the recurring-event guard skipped) instead of the master.
959+
*/
960+
export function normalizeMasterVEventFirst(icalData: string): string {
961+
const vevents = icalData.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) || [];
962+
if (vevents.length < 2) return icalData;
963+
const first = vevents[0];
964+
if (!first || !/^RECURRENCE-ID[;:]/m.test(first)) return icalData;
965+
const master = vevents.find(v => !/^RECURRENCE-ID[;:]/m.test(v));
966+
if (!master) return icalData;
967+
// Swap the two blocks. Function replacements avoid `$`-pattern expansion.
968+
const SENTINEL = '\u0000MASTER-VEVENT\u0000';
969+
let out = icalData.replace(master, () => SENTINEL);
970+
out = out.replace(first, () => master);
971+
out = out.replace(SENTINEL, () => first);
972+
return out;
973+
}
974+
923975
export class CalDAVCalendarClient {
924976
private config: CalDAVConfig;
925977
private client: DAVClient | null = null;
@@ -1164,14 +1216,18 @@ export class CalDAVCalendarClient {
11641216
const lineEnding = detectLineEnding(obj.data);
11651217
const fold = (line: string) => foldICalLine(line, lineEnding);
11661218

1219+
// All patch helpers target the FIRST VEVENT — make sure that's the master,
1220+
// not an overridden instance (component order is not guaranteed by RFC).
1221+
const normalizedData = normalizeMasterVEventFirst(obj.data);
1222+
11671223
// Capture original VEVENT before any patching for reads
1168-
const originalVevent = extractVEvent(obj.data);
1224+
const originalVevent = extractVEvent(normalizedData);
11691225
if (!originalVevent) {
11701226
throw new Error('Cannot update event: no VEVENT block found');
11711227
}
11721228

11731229
const existingUid = parseICalValue(originalVevent, 'UID') || eventId;
1174-
let data = obj.data;
1230+
let data = normalizedData;
11751231

11761232
// --- Recurring event guard ---
11771233
const hasRRule = /^RRULE[;:]/m.test(originalVevent);
@@ -1207,7 +1263,10 @@ export class CalDAVCalendarClient {
12071263
if (!recIdRaw) continue;
12081264
const recIdFormatted = formatICalDate(recIdRaw);
12091265
if (!recIdFormatted) continue;
1210-
const recDate = new Date(recIdFormatted);
1266+
// Parse naive datetimes as UTC to match rrule's naive-as-UTC
1267+
// convention — new Date() would use the process's local TZ and
1268+
// flag every valid exception as an orphan when TZ != UTC.
1269+
const recDate = parseICalDateAsUTC(recIdFormatted);
12111270
// Check if this recurrence-id still matches an occurrence
12121271
const matches = rule.between(
12131272
new Date(recDate.getTime() - 1000),

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { coerceRecipients, coerceBool, redactBearerTokens } from './coerce.js';
1616
const server = new Server(
1717
{
1818
name: 'fastmail-mcp',
19-
version: '1.10.0',
19+
version: '1.11.0',
2020
},
2121
{
2222
capabilities: {

0 commit comments

Comments
 (0)