caldav-adapter 8.3.0 → 8.3.1

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.
@@ -12,9 +12,19 @@ module.exports = async function (ctx) {
12
12
  if (ctx.logger) ctx.logger.warn(err);
13
13
  else if (ctx?.app?.emit) ctx.app.emit('error', err, ctx);
14
14
  else console.warn(err);
15
+ // Set body to empty string on error to prevent undefined issues
16
+ ctx.request.body = '';
15
17
  }
16
18
 
17
- if (ctx.request.type.includes('xml')) {
19
+ // Initialize xml to null by default
20
+ ctx.request.xml = null;
21
+
22
+ if (
23
+ ctx.request.type.includes('xml') && // Only attempt to parse if we have a non-empty body
24
+ ctx.request.body &&
25
+ typeof ctx.request.body === 'string' &&
26
+ ctx.request.body.trim()
27
+ ) {
18
28
  try {
19
29
  ctx.request.xml = new DOMParser().parseFromString(ctx.request.body);
20
30
  // Ensure we have a valid document, otherwise set to null
@@ -25,6 +35,7 @@ module.exports = async function (ctx) {
25
35
  if (ctx.logger) ctx.logger.warn(err);
26
36
  else if (ctx?.app?.emit) ctx.app.emit('error', err, ctx);
27
37
  else console.warn(err);
38
+ ctx.request.xml = null;
28
39
  }
29
40
  }
30
41
  };
package/common/x-build.js CHANGED
@@ -58,7 +58,10 @@ module.exports.multistatus = multistatus;
58
58
  const status = {
59
59
  200: 'HTTP/1.1 200 OK',
60
60
  403: 'HTTP/1.1 403 Forbidden',
61
- 404: 'HTTP/1.1 404 Not Found'
61
+ 404: 'HTTP/1.1 404 Not Found',
62
+ 409: 'HTTP/1.1 409 Conflict',
63
+ 424: 'HTTP/1.1 424 Failed Dependency',
64
+ 500: 'HTTP/1.1 500 Internal Server Error'
62
65
  };
63
66
  module.exports.status = status;
64
67
 
package/common/xml.js CHANGED
@@ -22,8 +22,10 @@ const select = xpath.useNamespaces(namespaces);
22
22
 
23
23
  function get(path, doc) {
24
24
  // Validate that doc is a proper XML document
25
+ // Return empty array for null/invalid documents instead of throwing
26
+ // This handles cases where the request body was not received or parsed
25
27
  if (!doc || typeof doc !== 'object') {
26
- throw new Error('Invalid XML document: document is null or not an object');
28
+ return [];
27
29
  }
28
30
 
29
31
  return select(path, doc);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "caldav-adapter",
3
3
  "description": "CalDAV server for Node.js and Koa. Modernized and maintained for Forward Email.",
4
- "version": "8.3.0",
4
+ "version": "8.3.1",
5
5
  "author": "Sanders DeNardi and Forward Email LLC",
6
6
  "contributors": [
7
7
  "Sanders DeNardi <sedenardi@gmail.com> (http://www.sandersdenardi.com/)",
@@ -1,9 +1,282 @@
1
1
  const _ = require('lodash');
2
2
  const xml = require('../../../common/xml');
3
- const { build, multistatus } = require('../../../common/x-build');
3
+ const {
4
+ build,
5
+ multistatus,
6
+ response,
7
+ status
8
+ } = require('../../../common/x-build');
4
9
  const { setMissingMethod } = require('../../../common/response');
5
10
  const commonTags = require('../../../common/tags');
6
11
 
12
+ // Protected properties that cannot be modified via PROPPATCH
13
+ // Per RFC 4791 Section 5.2.3 and related sections
14
+ const PROTECTED_PROPERTIES = new Set([
15
+ 'supported-calendar-component-set',
16
+ 'supported-calendar-data',
17
+ 'max-resource-size',
18
+ 'min-date-time',
19
+ 'max-date-time',
20
+ 'max-instances',
21
+ 'max-attendees-per-instance',
22
+ 'getctag', // CalendarServer extension, typically read-only
23
+ 'getetag',
24
+ 'getcontenttype',
25
+ 'getcontentlength',
26
+ 'getlastmodified',
27
+ 'creationdate',
28
+ 'resourcetype',
29
+ 'sync-token',
30
+ 'current-user-privilege-set',
31
+ 'owner',
32
+ 'supported-report-set'
33
+ ]);
34
+
35
+ // Properties that can be modified via PROPPATCH
36
+ const MODIFIABLE_PROPERTIES = new Set([
37
+ 'displayname',
38
+ 'calendar-description',
39
+ 'calendar-timezone',
40
+ 'calendar-color',
41
+ 'calendar-order'
42
+ ]);
43
+
44
+ /**
45
+ * Validate VTIMEZONE content per RFC 4791 Section 5.2.2
46
+ * The calendar-timezone property MUST be a valid iCalendar object
47
+ * containing exactly one valid VTIMEZONE component.
48
+ * @param {string} value - The timezone value to validate
49
+ * @returns {{valid: boolean, error: string|null}} - Validation result
50
+ */
51
+ function validateCalendarTimezone(value) {
52
+ // Empty value is valid (clearing the timezone)
53
+ if (!value || value.trim() === '') {
54
+ return { valid: true, error: null };
55
+ }
56
+
57
+ // Must contain VCALENDAR wrapper
58
+ if (!value.includes('BEGIN:VCALENDAR') || !value.includes('END:VCALENDAR')) {
59
+ return {
60
+ valid: false,
61
+ error:
62
+ 'calendar-timezone must be a valid iCalendar object (missing VCALENDAR)'
63
+ };
64
+ }
65
+
66
+ // Must contain exactly one VTIMEZONE component
67
+ const vtimezoneCount = (value.match(/BEGIN:VTIMEZONE/g) || []).length;
68
+ if (vtimezoneCount === 0) {
69
+ return {
70
+ valid: false,
71
+ error: 'calendar-timezone must contain a VTIMEZONE component'
72
+ };
73
+ }
74
+
75
+ if (vtimezoneCount > 1) {
76
+ return {
77
+ valid: false,
78
+ error: 'calendar-timezone must contain exactly one VTIMEZONE component'
79
+ };
80
+ }
81
+
82
+ // Must have matching END:VTIMEZONE
83
+ const endVtimezoneCount = (value.match(/END:VTIMEZONE/g) || []).length;
84
+ if (vtimezoneCount !== endVtimezoneCount) {
85
+ return {
86
+ valid: false,
87
+ error: 'calendar-timezone has malformed VTIMEZONE component'
88
+ };
89
+ }
90
+
91
+ // Must have TZID property
92
+ if (!value.includes('TZID:') && !value.includes('TZID;')) {
93
+ return {
94
+ valid: false,
95
+ error: 'VTIMEZONE component must have a TZID property'
96
+ };
97
+ }
98
+
99
+ return { valid: true, error: null };
100
+ }
101
+
102
+ /**
103
+ * Extract xml:lang attribute from an element
104
+ * Per RFC 4791 Section 5.2.1: Language tagging information appearing
105
+ * in the scope of the 'prop' element MUST be persistently stored
106
+ * @param {Element} child - XML element node
107
+ * @returns {string|null} - The xml:lang value or null
108
+ */
109
+ function extractXmlLang(child) {
110
+ // Check for xml:lang attribute on the element itself
111
+ if (child.getAttributeNS) {
112
+ const lang = child.getAttributeNS(
113
+ 'http://www.w3.org/XML/1998/namespace',
114
+ 'lang'
115
+ );
116
+ if (lang) return lang;
117
+ }
118
+
119
+ // Check for xml:lang attribute using getAttribute
120
+ if (child.getAttribute) {
121
+ const lang = child.getAttribute('xml:lang');
122
+ if (lang) return lang;
123
+ }
124
+
125
+ // Walk up the tree to find inherited xml:lang
126
+ let parent = child.parentNode;
127
+ while (parent && parent.getAttribute) {
128
+ const lang = parent.getAttribute('xml:lang');
129
+ if (lang) return lang;
130
+ parent = parent.parentNode;
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Process a 'set' property and return the update object
138
+ * @param {Element} child - XML element node
139
+ * @param {Array} validationErrors - Array to collect validation errors
140
+ * @returns {Object|null} - Update object or null if not handled
141
+ */
142
+ function processSetProperty(child, validationErrors) {
143
+ const xmlLang = extractXmlLang(child);
144
+
145
+ switch (child.localName) {
146
+ case 'displayname': {
147
+ // Allow empty string to clear the display name
148
+ const result = { name: child.textContent };
149
+ if (xmlLang) result.nameXmlLang = xmlLang;
150
+ return result;
151
+ }
152
+
153
+ case 'calendar-description': {
154
+ // Allow empty string to clear the description
155
+ // Per RFC 4791 Section 5.2.1, calendar-description is (#PCDATA)
156
+ const result = { description: child.textContent };
157
+ if (xmlLang) result.descriptionXmlLang = xmlLang;
158
+ return result;
159
+ }
160
+
161
+ case 'calendar-timezone': {
162
+ // Validate timezone per RFC 4791 Section 5.2.2
163
+ const validation = validateCalendarTimezone(child.textContent);
164
+ if (!validation.valid) {
165
+ validationErrors.push({
166
+ child,
167
+ error: validation.error,
168
+ precondition: 'valid-calendar-data'
169
+ });
170
+ return null;
171
+ }
172
+
173
+ return { timezone: child.textContent };
174
+ }
175
+
176
+ case 'calendar-color': {
177
+ // Allow empty string to clear the color
178
+ return { color: child.textContent };
179
+ }
180
+
181
+ case 'calendar-order': {
182
+ // For numeric values, parse if content exists, otherwise set to null
183
+ return {
184
+ order: child.textContent ? Number.parseInt(child.textContent, 10) : null
185
+ };
186
+ }
187
+
188
+ default: {
189
+ return null;
190
+ }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Process a 'remove' property and return the update object
196
+ * Per RFC 4918 Section 14.23: removing sets property to empty/null
197
+ * @param {Element} child - XML element node
198
+ * @returns {Object|null} - Update object or null if not handled
199
+ */
200
+ function processRemoveProperty(child) {
201
+ switch (child.localName) {
202
+ case 'displayname': {
203
+ return { name: '', nameXmlLang: null };
204
+ }
205
+
206
+ case 'calendar-description': {
207
+ return { description: '', descriptionXmlLang: null };
208
+ }
209
+
210
+ case 'calendar-timezone': {
211
+ return { timezone: '' };
212
+ }
213
+
214
+ case 'calendar-color': {
215
+ return { color: '' };
216
+ }
217
+
218
+ case 'calendar-order': {
219
+ return { order: null };
220
+ }
221
+
222
+ default: {
223
+ // Per RFC 4918: removing non-existent property is not an error
224
+ return null;
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Build error response for protected property modification attempt
231
+ * Per RFC 4918 Section 9.2: atomicity requires all or nothing
232
+ * @param {string} url - Request URL
233
+ * @param {Array} allChildren - All property children
234
+ * @param {Array} protectedErrors - Protected property elements
235
+ * @returns {string} - XML response
236
+ */
237
+ function buildProtectedPropertyErrorResponse(
238
+ url,
239
+ allChildren,
240
+ protectedErrors
241
+ ) {
242
+ const responses = allChildren.map(({ child }) => {
243
+ const propName = `${child.prefix || 'D'}:${child.localName}`;
244
+ if (protectedErrors.includes(child)) {
245
+ // Return 403 for protected properties
246
+ return response(url, status[403], [{ [propName]: '' }]);
247
+ }
248
+
249
+ // Return 424 Failed Dependency for other properties
250
+ return response(url, status[424], [{ [propName]: '' }]);
251
+ });
252
+
253
+ return build(multistatus(_.compact(responses)));
254
+ }
255
+
256
+ /**
257
+ * Build error response for validation failures
258
+ * Per RFC 4918 Section 9.2: atomicity requires all or nothing
259
+ * @param {string} url - Request URL
260
+ * @param {Array} allChildren - All property children
261
+ * @param {Array} validationErrors - Validation error objects
262
+ * @returns {string} - XML response
263
+ */
264
+ function buildValidationErrorResponse(url, allChildren, validationErrors) {
265
+ const errorChildren = new Set(validationErrors.map((e) => e.child));
266
+ const responses = allChildren.map(({ child }) => {
267
+ const propName = `${child.prefix || 'D'}:${child.localName}`;
268
+ if (errorChildren.has(child)) {
269
+ // Return 409 Conflict for validation errors (per RFC 4791 Section 5.3.2.1)
270
+ return response(url, status[409], [{ [propName]: '' }]);
271
+ }
272
+
273
+ // Return 424 Failed Dependency for other properties
274
+ return response(url, status[424], [{ [propName]: '' }]);
275
+ });
276
+
277
+ return build(multistatus(_.compact(responses)));
278
+ }
279
+
7
280
  module.exports = function (options) {
8
281
  const tags = commonTags(options);
9
282
 
@@ -13,73 +286,143 @@ module.exports = function (options) {
13
286
  return;
14
287
  }
15
288
 
16
- const { children } = xml.getWithChildren(
289
+ //
290
+ // Per RFC 4918 Section 9.2:
291
+ // - Servers MUST process PROPPATCH instructions in document order
292
+ // - Instructions MUST either all be executed or none executed (atomicity)
293
+ // - The propertyupdate element can contain both 'set' and 'remove' instructions
294
+ //
295
+ const { children: setChildren } = xml.getWithChildren(
17
296
  '/D:propertyupdate/D:set/D:prop',
18
297
  ctx.request.xml
19
298
  );
20
299
 
21
- const updates = {};
22
- for (const child of children) {
23
- if (!child.localName || !child.textContent) continue;
24
- switch (child.localName) {
25
- case 'displayname': {
26
- updates.name = child.textContent;
27
-
28
- break;
29
- }
300
+ const { children: removeChildren } = xml.getWithChildren(
301
+ '/D:propertyupdate/D:remove/D:prop',
302
+ ctx.request.xml
303
+ );
30
304
 
31
- case 'calendar-description': {
32
- updates.description = child.textContent;
305
+ const updates = {};
306
+ const protectedErrors = [];
307
+ const validationErrors = [];
308
+ const allChildren = [];
33
309
 
34
- break;
35
- }
310
+ // Process 'set' instructions
311
+ for (const child of setChildren) {
312
+ if (!child.localName) continue;
313
+ allChildren.push({ child, action: 'set' });
36
314
 
37
- case 'calendar-timezone': {
38
- updates.timezone = child.textContent;
315
+ if (PROTECTED_PROPERTIES.has(child.localName)) {
316
+ protectedErrors.push(child);
317
+ continue;
318
+ }
39
319
 
40
- break;
41
- }
320
+ const update = processSetProperty(child, validationErrors);
321
+ if (update) {
322
+ Object.assign(updates, update);
323
+ }
324
+ }
42
325
 
43
- case 'calendar-color': {
44
- updates.color = child.textContent;
326
+ // Process 'remove' instructions
327
+ for (const child of removeChildren) {
328
+ if (!child.localName) continue;
329
+ allChildren.push({ child, action: 'remove' });
45
330
 
46
- break;
47
- }
331
+ if (PROTECTED_PROPERTIES.has(child.localName)) {
332
+ protectedErrors.push(child);
333
+ continue;
334
+ }
48
335
 
49
- case 'calendar-order': {
50
- updates.order = Number.parseInt(child.textContent, 10);
336
+ const update = processRemoveProperty(child);
337
+ if (update) {
338
+ Object.assign(updates, update);
339
+ }
340
+ }
51
341
 
52
- break;
53
- }
342
+ //
343
+ // Atomicity check: If any errors, fail entire request
344
+ // Per RFC 4918 Section 9.2: "Instructions MUST either all be executed or none executed"
345
+ //
54
346
 
55
- // TODO: finish me
347
+ // Check for protected property errors first
348
+ if (protectedErrors.length > 0) {
349
+ return buildProtectedPropertyErrorResponse(
350
+ ctx.url,
351
+ allChildren,
352
+ protectedErrors
353
+ );
354
+ }
56
355
 
57
- // No default
356
+ // Check for validation errors (e.g., invalid timezone)
357
+ if (validationErrors.length > 0) {
358
+ // Log validation errors for debugging
359
+ for (const { child, error } of validationErrors) {
360
+ const err = new Error(
361
+ `CalDAV PROPPATCH validation error for ${child.localName}: ${error}`
362
+ );
363
+ err.isCodeBug = false;
364
+ console.warn(err.message);
365
+ if (ctx.logger) ctx.logger.warn(err.message);
58
366
  }
367
+
368
+ return buildValidationErrorResponse(
369
+ ctx.url,
370
+ allChildren,
371
+ validationErrors
372
+ );
59
373
  }
60
374
 
61
- //
62
- // if updates was empty then we should log so we can alert admins
63
- // as to what other properties clients are attempting to update
64
- // (this code is an anti-pattern but temporary so we can improve)
65
- //
66
- if (_.isEmpty(updates)) {
67
- const err = new TypeError('CalDAV PROPPATCH missing fields');
68
- err.isCodeBug = true; // Specific to Forward Email (can be removed later)
69
- err.str = ctx.request.body; // Sensitive and should be removed later
375
+ // Log warning for unhandled properties
376
+ const unhandledProps = allChildren
377
+ .filter(
378
+ ({ child }) =>
379
+ child.localName &&
380
+ !MODIFIABLE_PROPERTIES.has(child.localName) &&
381
+ !PROTECTED_PROPERTIES.has(child.localName)
382
+ )
383
+ .map(({ child }) => child.localName);
384
+
385
+ if (unhandledProps.length > 0) {
386
+ const err = new TypeError(
387
+ `CalDAV PROPPATCH unhandled properties: ${unhandledProps.join(', ')}`
388
+ );
389
+ err.isCodeBug = true;
390
+ err.str = ctx.request.body;
70
391
  err.xml = ctx.request.xml;
71
392
  console.error(err);
72
393
  if (ctx.logger) ctx.logger.error(err);
73
394
  }
74
395
 
75
- const updatedCalendar = await options.data.updateCalendar(ctx, {
76
- principalId: ctx.state.params.principalId,
77
- calendarId: ctx.state.params.calendarId,
78
- user: ctx.state.user,
79
- updates
80
- });
396
+ //
397
+ // Apply updates atomically
398
+ // The updateCalendar function should handle rollback on failure
399
+ //
400
+ let updatedCalendar = calendar;
401
+ if (!_.isEmpty(updates)) {
402
+ try {
403
+ updatedCalendar = await options.data.updateCalendar(ctx, {
404
+ principalId: ctx.state.params.principalId,
405
+ calendarId: ctx.state.params.calendarId,
406
+ user: ctx.state.user,
407
+ updates
408
+ });
409
+ } catch (err) {
410
+ // If update fails, return 500 for all properties (atomicity)
411
+ console.error('CalDAV PROPPATCH update failed:', err);
412
+ if (ctx.logger)
413
+ ctx.logger.error('CalDAV PROPPATCH update failed:', err);
414
+
415
+ const responses = allChildren.map(({ child }) => {
416
+ const propName = `${child.prefix || 'D'}:${child.localName}`;
417
+ return response(ctx.url, status[500], [{ [propName]: '' }]);
418
+ });
419
+
420
+ return build(multistatus(_.compact(responses)));
421
+ }
422
+ }
81
423
 
82
- const actions = _.map(children, async (child) => {
424
+ // Build response for each property
425
+ const actions = allChildren.map(async ({ child }) => {
83
426
  return tags.getResponse({
84
427
  resource: 'calendarProppatch',
85
428
  child,
@@ -1,5 +1,4 @@
1
- const { notFound } = require('../../../common/x-build');
2
- // const { notFound, preconditionFail } = require('../../../common/x-build');
1
+ const { notFound, preconditionFail } = require('../../../common/x-build');
3
2
  const { setMissingMethod } = require('../../../common/response');
4
3
  const winston = require('../../../common/winston');
5
4
 
@@ -57,14 +56,18 @@ module.exports = function (options) {
57
56
  log.debug(`existing event${existing ? '' : ' not'} found`);
58
57
 
59
58
  if (existing) {
60
- /*
59
+ //
60
+ // RFC 7232 Section 3.2: If-None-Match
61
+ // When a client sends "If-None-Match: *", the server MUST NOT perform
62
+ // the requested method if the target resource exists.
63
+ // This is used by clients to prevent overwriting existing resources.
64
+ //
61
65
  if (ctx.get('if-none-match') === '*') {
62
66
  log.warn('if-none-match: * header present, precondition failed');
63
67
  ctx.status = 412;
64
68
  ctx.body = preconditionFail(ctx.url, 'no-uid-conflict');
65
69
  return;
66
70
  }
67
- */
68
71
 
69
72
  const updateObject = await options.data.updateEvent(ctx, {
70
73
  eventId: ctx.state.params.eventId,
@@ -1,5 +1,6 @@
1
1
  const _ = require('lodash');
2
2
  const xml = require('../../../common/xml');
3
+ const winston = require('../../../common/winston');
3
4
  const {
4
5
  build,
5
6
  multistatus,
@@ -10,10 +11,20 @@ const calPropfind = require('../calendar/propfind');
10
11
  const commonTags = require('../../../common/tags');
11
12
 
12
13
  module.exports = function (options) {
14
+ const log = winston({ ...options, label: 'user/propfind' });
13
15
  const { calendarResponse } = calPropfind(options);
14
16
  const tags = commonTags(options);
15
17
 
16
18
  const exec = async function (ctx) {
19
+ // Handle missing or invalid XML body gracefully
20
+ // This can happen when the client connection is interrupted
21
+ // or the body was not received properly
22
+ if (!ctx.request.xml) {
23
+ log.warn('PROPFIND request received with missing or invalid XML body');
24
+ // Return a minimal valid response for allprop
25
+ // RFC 4918 Section 9.1: If no body is included, the request MUST be treated as allprop
26
+ }
27
+
17
28
  const { children } = xml.getWithChildren(
18
29
  '/D:propfind/D:prop',
19
30
  ctx.request.xml