@forcecalendar/core 2.1.20 → 2.1.22

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.
@@ -7,6 +7,15 @@
7
7
  import { TimezoneManager } from '../timezone/TimezoneManager.js';
8
8
 
9
9
  export class Event {
10
+ // Field size limits
11
+ static FIELD_LIMITS = {
12
+ id: 256,
13
+ title: 1000,
14
+ description: 10000,
15
+ location: 500
16
+ };
17
+ static MAX_METADATA_SIZE = 50 * 1024; // 50KB
18
+
10
19
  /**
11
20
  * Normalize event data
12
21
  * @param {import('../../types.js').EventData} data - Raw event data
@@ -37,11 +46,15 @@ export class Event {
37
46
  }
38
47
  }
39
48
 
40
- // Normalize string fields
41
- normalized.id = String(normalized.id || '').trim();
42
- normalized.title = String(normalized.title || '').trim();
43
- normalized.description = String(normalized.description || '').trim();
44
- normalized.location = String(normalized.location || '').trim();
49
+ // Normalize string fields with size limits
50
+ normalized.id = String(normalized.id || '').trim().slice(0, Event.FIELD_LIMITS.id);
51
+ normalized.title = String(normalized.title || '').trim().slice(0, Event.FIELD_LIMITS.title);
52
+ normalized.description = String(normalized.description || '')
53
+ .trim()
54
+ .slice(0, Event.FIELD_LIMITS.description);
55
+ normalized.location = String(normalized.location || '')
56
+ .trim()
57
+ .slice(0, Event.FIELD_LIMITS.location);
45
58
 
46
59
  // Normalize arrays
47
60
  normalized.attendees = Array.isArray(normalized.attendees) ? normalized.attendees : [];
@@ -295,8 +308,8 @@ export class Event {
295
308
  // Conference/Virtual meeting
296
309
  this.conferenceData = normalized.conferenceData;
297
310
 
298
- // Custom metadata for extensibility
299
- this.metadata = { ...normalized.metadata };
311
+ // Custom metadata for extensibility (with size/type validation)
312
+ this.metadata = Event._sanitizeMetadata(normalized.metadata);
300
313
 
301
314
  // Computed properties cache
302
315
  this._cache = {};
@@ -816,6 +829,46 @@ export class Event {
816
829
  return categories.every(category => this.hasCategory(category));
817
830
  }
818
831
 
832
+ // ============ Metadata Sanitization ============
833
+
834
+ /**
835
+ * Sanitize metadata to enforce size limits and reject unsafe types
836
+ * @param {Object} metadata - Raw metadata object
837
+ * @returns {Object} Sanitized metadata
838
+ * @private
839
+ */
840
+ static _sanitizeMetadata(metadata) {
841
+ if (!metadata || typeof metadata !== 'object') {
842
+ return {};
843
+ }
844
+
845
+ const sanitized = {};
846
+ for (const [key, value] of Object.entries(metadata)) {
847
+ // Reject functions and symbols
848
+ if (typeof value === 'function' || typeof value === 'symbol') {
849
+ continue;
850
+ }
851
+ sanitized[key] = value;
852
+ }
853
+
854
+ // Enforce serialized size limit
855
+ let serialized;
856
+ try {
857
+ serialized = JSON.stringify(sanitized);
858
+ } catch {
859
+ // If metadata can't be serialized (circular refs, etc.), return empty
860
+ return {};
861
+ }
862
+
863
+ if (serialized.length > Event.MAX_METADATA_SIZE) {
864
+ throw new Error(
865
+ `Event metadata exceeds maximum size of ${Event.MAX_METADATA_SIZE / 1024}KB when serialized`
866
+ );
867
+ }
868
+
869
+ return sanitized;
870
+ }
871
+
819
872
  // ============ Validation Methods ============
820
873
 
821
874
  /**
@@ -234,9 +234,30 @@ export class EventStore {
234
234
 
235
235
  // Filter by month
236
236
  if (filters.month && filters.year) {
237
- const monthKey = `${filters.year}-${String(filters.month).padStart(2, '0')}`;
238
- const eventIds = this.indices.byMonth.get(monthKey) || new Set();
239
- results = results.filter(event => eventIds.has(event.id));
237
+ // Collect candidates from target month AND adjacent months to handle
238
+ // timezone boundary issues (events indexed in the event's own timezone
239
+ // may fall in a different month than the query month)
240
+ const candidateIds = new Set();
241
+ for (let offset = -1; offset <= 1; offset++) {
242
+ let m = filters.month + offset;
243
+ let y = filters.year;
244
+ if (m < 1) { m = 12; y--; }
245
+ if (m > 12) { m = 1; y++; }
246
+ const key = `${y}-${String(m).padStart(2, '0')}`;
247
+ const ids = this.indices.byMonth.get(key);
248
+ if (ids) {
249
+ ids.forEach(id => candidateIds.add(id));
250
+ }
251
+ }
252
+
253
+ // Post-filter: only include events that actually overlap with the requested month
254
+ const monthStart = new Date(filters.year, filters.month - 1, 1);
255
+ const monthEnd = new Date(filters.year, filters.month, 0, 23, 59, 59, 999);
256
+
257
+ results = results.filter(event => {
258
+ if (!candidateIds.has(event.id)) return false;
259
+ return event.start <= monthEnd && event.end >= monthStart;
260
+ });
240
261
  }
241
262
 
242
263
  // Filter by all-day events
@@ -63,7 +63,10 @@ export class RRuleParser {
63
63
  break;
64
64
 
65
65
  case 'BYWEEKNO':
66
- rule.byWeekNo = this.parseIntList(value);
66
+ // RFC 5545: valid range is 1-53 or -53 to -1 (no zero)
67
+ rule.byWeekNo = this.parseIntList(value).filter(
68
+ v => v !== 0 && v >= -53 && v <= 53
69
+ );
67
70
  break;
68
71
 
69
72
  case 'BYMONTH':
@@ -242,6 +245,7 @@ export class RRuleParser {
242
245
  rule.byMonth = validateArray(rule.byMonth || [], 1, 12);
243
246
  rule.byMonthDay = validateArray(rule.byMonthDay || [], -31, 31).filter(v => v !== 0);
244
247
  rule.byYearDay = validateArray(rule.byYearDay || [], -366, 366).filter(v => v !== 0);
248
+ // RFC 5545: BYWEEKNO valid range is 1-53 or -53 to -1
245
249
  rule.byWeekNo = validateArray(rule.byWeekNo || [], -53, 53).filter(v => v !== 0);
246
250
  rule.byHour = validateArray(rule.byHour || [], 0, 23);
247
251
  rule.byMinute = validateArray(rule.byMinute || [], 0, 59);
@@ -7,6 +7,9 @@ import { RRuleParser } from './RRuleParser.js';
7
7
  * Full support for RFC 5545 (iCalendar) RRULE specification
8
8
  */
9
9
  export class RecurrenceEngine {
10
+ // Hard limit to prevent resource exhaustion regardless of caller input
11
+ static MAX_OCCURRENCES_HARD_LIMIT = 10000;
12
+
10
13
  /**
11
14
  * Expand a recurring event into individual occurrences
12
15
  * @param {import('./Event.js').Event} event - The recurring event
@@ -17,6 +20,8 @@ export class RecurrenceEngine {
17
20
  * @returns {import('../../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
18
21
  */
19
22
  static expandEvent(event, rangeStart, rangeEnd, maxOccurrences = 365, timezone = null) {
23
+ // Enforce hard limit regardless of caller-provided value
24
+ maxOccurrences = Math.min(maxOccurrences, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT);
20
25
  if (!event.recurring || !event.recurrenceRule) {
21
26
  return [{ start: event.start, end: event.end, timezone: event.timeZone }];
22
27
  }
@@ -7,6 +7,9 @@ import { TimezoneManager } from '../timezone/TimezoneManager.js';
7
7
  import { RRuleParser } from './RRuleParser.js';
8
8
 
9
9
  export class RecurrenceEngineV2 {
10
+ // Hard limit to prevent resource exhaustion regardless of caller input
11
+ static MAX_OCCURRENCES_HARD_LIMIT = 10000;
12
+
10
13
  constructor() {
11
14
  // Use singleton to share cache across all components
12
15
  this.tzManager = TimezoneManager.getInstance();
@@ -32,13 +35,16 @@ export class RecurrenceEngineV2 {
32
35
  */
33
36
  expandEvent(event, rangeStart, rangeEnd, options = {}) {
34
37
  const {
35
- maxOccurrences = 365,
38
+ maxOccurrences: requestedMax = 365,
36
39
  includeModified = true,
37
40
  includeCancelled = false,
38
41
  timezone = event.timeZone || 'UTC',
39
42
  handleDST = true
40
43
  } = options;
41
44
 
45
+ // Enforce hard limit regardless of caller-provided value
46
+ const maxOccurrences = Math.min(requestedMax, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
47
+
42
48
  // Check cache
43
49
  const cacheKey = this.getCacheKey(event.id, rangeStart, rangeEnd, options);
44
50
  if (this.occurrenceCache.has(cacheKey)) {
@@ -31,6 +31,13 @@ export class ICSHandler {
31
31
  // Get ICS string from input
32
32
  const icsString = await this.getICSString(input);
33
33
 
34
+ // Enforce input size limit before parsing
35
+ if (typeof icsString === 'string' && icsString.length > ICSParser.MAX_INPUT_SIZE) {
36
+ throw new Error(
37
+ `ICS input exceeds maximum size of ${ICSParser.MAX_INPUT_SIZE / (1024 * 1024)}MB`
38
+ );
39
+ }
40
+
34
41
  // Parse ICS to events
35
42
  const parsedEvents = this.parser.parse(icsString);
36
43
 
@@ -172,18 +179,86 @@ export class ICSHandler {
172
179
  * @returns {Promise<Object>} Import results
173
180
  */
174
181
  async importFromURL(url, options = {}) {
182
+ // Validate URL before fetching to prevent SSRF
183
+ ICSHandler.validateURL(url);
184
+
175
185
  try {
176
- const response = await fetch(url);
186
+ const controller = new AbortController();
187
+ const timeout = setTimeout(() => controller.abort(), 30000); // 30s timeout
188
+
189
+ const response = await fetch(url, { signal: controller.signal });
190
+ clearTimeout(timeout);
191
+
177
192
  if (!response.ok) {
178
193
  throw new Error(`Failed to fetch ICS: ${response.statusText}`);
179
194
  }
195
+
196
+ // Validate Content-Type header
197
+ const contentType = response.headers.get('content-type') || '';
198
+ const allowedTypes = ['text/calendar', 'text/plain', 'application/octet-stream'];
199
+ const typeMatch = allowedTypes.some(t => contentType.toLowerCase().includes(t));
200
+ if (contentType && !typeMatch) {
201
+ throw new Error(
202
+ `Unexpected Content-Type: ${contentType}. Expected text/calendar or text/plain`
203
+ );
204
+ }
205
+
180
206
  const icsString = await response.text();
181
207
  return this.import(icsString, options);
182
208
  } catch (error) {
209
+ if (error.name === 'AbortError') {
210
+ throw new Error('Failed to import from URL: request timed out after 30 seconds');
211
+ }
183
212
  throw new Error(`Failed to import from URL: ${error.message}`);
184
213
  }
185
214
  }
186
215
 
216
+ /**
217
+ * Validate a URL for safety (prevent SSRF attacks)
218
+ * @param {string} url - URL to validate
219
+ * @throws {Error} If URL is not safe
220
+ */
221
+ static validateURL(url) {
222
+ let parsed;
223
+ try {
224
+ parsed = new URL(url);
225
+ } catch {
226
+ throw new Error('Invalid URL');
227
+ }
228
+
229
+ // Only allow http and https schemes
230
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
231
+ throw new Error(`URL scheme "${parsed.protocol}" is not allowed. Only http and https are permitted`);
232
+ }
233
+
234
+ const hostname = parsed.hostname.toLowerCase();
235
+
236
+ // Block localhost
237
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
238
+ throw new Error('URLs pointing to localhost are not allowed');
239
+ }
240
+
241
+ // Block private/internal IP ranges
242
+ const privatePatterns = [
243
+ /^127\./, // 127.0.0.0/8
244
+ /^10\./, // 10.0.0.0/8
245
+ /^192\.168\./, // 192.168.0.0/16
246
+ /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
247
+ /^169\.254\./, // 169.254.0.0/16 (link-local)
248
+ /^0\./, // 0.0.0.0/8
249
+ /^\[?fe80:/i, // IPv6 link-local
250
+ /^\[?fc00:/i, // IPv6 unique local
251
+ /^\[?fd/i, // IPv6 unique local
252
+ /^\[?::1\]?$/ // IPv6 loopback
253
+ ];
254
+
255
+ for (const pattern of privatePatterns) {
256
+ if (pattern.test(hostname)) {
257
+ throw new Error('URLs pointing to private/internal networks are not allowed');
258
+ }
259
+ }
260
+ }
261
+
187
262
  /**
188
263
  * Subscribe to calendar feed
189
264
  * @param {string} url - URL to ICS feed
@@ -191,6 +266,9 @@ export class ICSHandler {
191
266
  * @returns {Object} Subscription object
192
267
  */
193
268
  subscribe(url, options = {}) {
269
+ // Validate URL before subscribing to prevent SSRF
270
+ ICSHandler.validateURL(url);
271
+
194
272
  const {
195
273
  refreshInterval = 3600000, // 1 hour default
196
274
  autoRefresh = true,
@@ -5,6 +5,11 @@
5
5
  */
6
6
 
7
7
  export class ICSParser {
8
+ // Size limits to prevent DoS attacks
9
+ static MAX_INPUT_SIZE = 50 * 1024 * 1024; // 50MB
10
+ static MAX_LINES = 100000; // 100k lines
11
+ static MAX_EVENTS = 10000; // 10k events
12
+
8
13
  constructor() {
9
14
  // ICS line folding max width
10
15
  this.maxLineLength = 75;
@@ -33,8 +38,21 @@ export class ICSParser {
33
38
  * @returns {Array} Array of event objects
34
39
  */
35
40
  parse(icsString) {
41
+ // Enforce input size limit
42
+ if (typeof icsString === 'string' && icsString.length > ICSParser.MAX_INPUT_SIZE) {
43
+ throw new Error(
44
+ `ICS input exceeds maximum size of ${ICSParser.MAX_INPUT_SIZE / (1024 * 1024)}MB`
45
+ );
46
+ }
47
+
36
48
  const events = [];
37
49
  const lines = this.unfoldLines(icsString);
50
+
51
+ // Enforce line count limit
52
+ if (lines.length > ICSParser.MAX_LINES) {
53
+ throw new Error(`ICS input exceeds maximum of ${ICSParser.MAX_LINES} lines`);
54
+ }
55
+
38
56
  let currentEvent = null;
39
57
  let inEvent = false;
40
58
  let inAlarm = false;
@@ -57,6 +75,10 @@ export class ICSParser {
57
75
  // Handle component boundaries
58
76
  if (property === 'BEGIN') {
59
77
  if (value === 'VEVENT') {
78
+ // Enforce event count limit
79
+ if (events.length >= ICSParser.MAX_EVENTS) {
80
+ throw new Error(`ICS input exceeds maximum of ${ICSParser.MAX_EVENTS} events`);
81
+ }
60
82
  inEvent = true;
61
83
  currentEvent = this.createEmptyEvent();
62
84
  } else if (value === 'VALARM') {
@@ -119,22 +119,14 @@ export class AdaptiveMemoryManager {
119
119
  }
120
120
  }
121
121
 
122
- // Node.js environment - use fully indirect access to avoid LWC static analysis
123
- // Salesforce Locker Service blocks any reference to process.memoryUsage
122
+ // Node.js environment
124
123
  try {
125
- const g = typeof globalThis !== 'undefined' ? globalThis : {};
126
- const procKey = 'proc' + 'ess';
127
- const memKey = 'mem' + 'oryUsage';
128
- const p = g[procKey];
129
- if (p && typeof p === 'object') {
130
- const memFn = p[memKey];
131
- if (typeof memFn === 'function') {
132
- const usage = memFn.call(p);
133
- return usage.heapUsed / usage.heapTotal;
134
- }
124
+ if (typeof process !== 'undefined' && typeof process.memoryUsage === 'function') {
125
+ const usage = process.memoryUsage();
126
+ return usage.heapUsed / usage.heapTotal;
135
127
  }
136
128
  } catch (e) {
137
- // Ignore - not in Node.js environment
129
+ // Ignore - not available in this environment (e.g., Salesforce Locker Service)
138
130
  }
139
131
 
140
132
  // Fallback - estimate based on cache sizes
@@ -102,23 +102,25 @@ export class StateManager {
102
102
  updates = updates(oldState);
103
103
  }
104
104
 
105
- // Sanitize keys to prevent prototype pollution
105
+ // Deep sanitize to prevent prototype pollution
106
106
  if (updates && typeof updates === 'object') {
107
- delete updates.__proto__;
108
- delete updates.constructor;
109
- delete updates.prototype;
107
+ updates = StateManager._deepSanitize(updates);
110
108
  }
111
109
 
112
110
  // Create new state with updates
113
111
  const newState = {
114
112
  ...oldState,
115
113
  ...updates,
116
- // Preserve nested objects
117
- filters: updates.filters ? { ...oldState.filters, ...updates.filters } : oldState.filters,
114
+ // Preserve nested objects (sanitization already applied to updates)
115
+ filters: updates.filters
116
+ ? { ...oldState.filters, ...StateManager._deepSanitize(updates.filters) }
117
+ : oldState.filters,
118
118
  businessHours: updates.businessHours
119
- ? { ...oldState.businessHours, ...updates.businessHours }
119
+ ? { ...oldState.businessHours, ...StateManager._deepSanitize(updates.businessHours) }
120
120
  : oldState.businessHours,
121
- metadata: updates.metadata ? { ...oldState.metadata, ...updates.metadata } : oldState.metadata
121
+ metadata: updates.metadata
122
+ ? { ...oldState.metadata, ...StateManager._deepSanitize(updates.metadata) }
123
+ : oldState.metadata
122
124
  };
123
125
 
124
126
  // Check if state actually changed
@@ -406,6 +408,51 @@ export class StateManager {
406
408
  this.historyIndex = 0;
407
409
  }
408
410
 
411
+ /**
412
+ * Recursively sanitize an object to prevent prototype pollution
413
+ * Removes dangerous keys (__proto__, constructor, prototype) at all levels
414
+ * @param {*} obj - Object to sanitize
415
+ * @param {number} depth - Current recursion depth
416
+ * @returns {*} Sanitized object
417
+ * @private
418
+ */
419
+ static _deepSanitize(obj, depth = 0) {
420
+ // Prevent recursion attacks
421
+ if (depth > 10) {
422
+ return {};
423
+ }
424
+
425
+ if (obj === null || typeof obj !== 'object') {
426
+ return obj;
427
+ }
428
+
429
+ // Don't sanitize Date objects or arrays of primitives
430
+ if (obj instanceof Date) {
431
+ return obj;
432
+ }
433
+
434
+ if (Array.isArray(obj)) {
435
+ return obj.map(item => StateManager._deepSanitize(item, depth + 1));
436
+ }
437
+
438
+ const sanitized = {};
439
+ const dangerousKeys = new Set(['__proto__', 'constructor', 'prototype']);
440
+
441
+ for (const key of Object.keys(obj)) {
442
+ if (dangerousKeys.has(key)) {
443
+ continue;
444
+ }
445
+ const value = obj[key];
446
+ if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
447
+ sanitized[key] = StateManager._deepSanitize(value, depth + 1);
448
+ } else {
449
+ sanitized[key] = value;
450
+ }
451
+ }
452
+
453
+ return sanitized;
454
+ }
455
+
409
456
  /**
410
457
  * Check if state has changed
411
458
  * @private
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.1.20",
3
+ "version": "2.1.22",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",