@remit/calendar-service 0.0.5 → 0.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/calendar-service",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/feed.test.ts CHANGED
@@ -49,6 +49,10 @@ const object = (
49
49
  updatedAt,
50
50
  }) as CalendarObjectItem;
51
51
 
52
+ /** Content lines as a subscriber reads them, with RFC 5545 3.1 folding undone. */
53
+ const unfolded = (icalData: string): string =>
54
+ icalData.replace(/\r\n[ \t]/g, "");
55
+
52
56
  describe("a feed token", () => {
53
57
  it("is base64url over the declared number of random bytes", () => {
54
58
  const minted = mintCalendarFeedToken();
@@ -122,6 +126,49 @@ describe("the calendar a feed serves", () => {
122
126
  assert.match(feed.icalData, /SUMMARY:Stand-up/);
123
127
  });
124
128
 
129
+ it("escapes the separators a name may carry", () => {
130
+ const feed = buildCalendarFeed(
131
+ collection({ displayName: "Work, Home; C:\\shared" }),
132
+ [],
133
+ );
134
+
135
+ assert.match(
136
+ unfolded(feed.icalData),
137
+ /X-WR-CALNAME:Work\\, Home\\; C:\\\\shared\r\n/,
138
+ );
139
+ });
140
+
141
+ it("cannot be made to smuggle a component through its name", () => {
142
+ const feed = buildCalendarFeed(
143
+ collection({
144
+ displayName:
145
+ "Work\r\nBEGIN:VEVENT\r\nUID:injected\r\nDTSTART:20250101T000000Z\r\nSUMMARY:pwned\r\nEND:VEVENT",
146
+ }),
147
+ [
148
+ object(
149
+ "a.ics",
150
+ singleEvent("SUMMARY:Stand-up", "DTSTART:20260907T090000Z"),
151
+ ),
152
+ ],
153
+ );
154
+ const served = unfolded(feed.icalData);
155
+
156
+ assert.equal(served.match(/^BEGIN:VEVENT\r\n/gm)?.length, 1);
157
+ assert.doesNotMatch(served, /^UID:injected/m);
158
+ assert.doesNotMatch(served, /^SUMMARY:pwned/m);
159
+ assert.match(served, /X-WR-CALNAME:Work\\nBEGIN:VEVENT\\nUID:injected/);
160
+ });
161
+
162
+ it("leaves no bare carriage return in what it serves", () => {
163
+ const feed = buildCalendarFeed(
164
+ collection({ displayName: "Work\rHome" }),
165
+ [],
166
+ );
167
+
168
+ assert.match(unfolded(feed.icalData), /X-WR-CALNAME:Work\\nHome\r\n/);
169
+ assert.doesNotMatch(feed.icalData, /\r(?!\n)/);
170
+ });
171
+
125
172
  it("carries the collection's zone so a client reads floating times the same way", () => {
126
173
  const feed = buildCalendarFeed(
127
174
  collection({ timezone: "Europe/Amsterdam" }),
package/src/feed.ts CHANGED
@@ -80,6 +80,24 @@ export const redactCalendarFeedPath = (path: string): string =>
80
80
  ? path
81
81
  : `${CALENDAR_FEED_PATH_PREFIX}<redacted>${CALENDAR_FEED_PATH_SUFFIX}`;
82
82
 
83
+ /**
84
+ * A TEXT value as RFC 5545 3.3.11 writes one.
85
+ *
86
+ * ical.js escapes TEXT only for the properties in its design set, and no `X-`
87
+ * property is in it, so a value goes out verbatim: a comma or a semicolon
88
+ * reads back as a value list, and a line break as the start of a whole
89
+ * component the subscriber never had. TEXT has no encoding for a control
90
+ * character, so one is dropped rather than served.
91
+ */
92
+ const escapeIcalText = (value: string): string =>
93
+ value
94
+ .replace(/\\/g, "\\\\")
95
+ .replace(/;/g, "\\;")
96
+ .replace(/,/g, "\\,")
97
+ .replace(/\r\n|\r|\n/g, "\\n")
98
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: RFC 5545 TEXT excludes CONTROL.
99
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
100
+
83
101
  /** The bytes a feed serves, and what a conditional request needs to skip them. */
84
102
  export interface CalendarFeed {
85
103
  icalData: string;
@@ -106,9 +124,15 @@ export const buildCalendarFeed = (
106
124
  feed.updatePropertyWithValue("prodid", CALENDAR_PRODID);
107
125
  feed.updatePropertyWithValue("version", "2.0");
108
126
  feed.updatePropertyWithValue("calscale", "GREGORIAN");
109
- feed.updatePropertyWithValue("x-wr-calname", collection.displayName);
127
+ feed.updatePropertyWithValue(
128
+ "x-wr-calname",
129
+ escapeIcalText(collection.displayName),
130
+ );
110
131
  if (collection.timezone !== "") {
111
- feed.updatePropertyWithValue("x-wr-timezone", collection.timezone);
132
+ feed.updatePropertyWithValue(
133
+ "x-wr-timezone",
134
+ escapeIcalText(collection.timezone),
135
+ );
112
136
  }
113
137
 
114
138
  const zonesSeen = new Set<string>();