@forcecalendar/core 2.1.19 → 2.1.21
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/README.md +1 -1
- package/core/events/Event.js +60 -7
- package/core/events/RRuleParser.js +5 -1
- package/core/events/RecurrenceEngine.js +5 -0
- package/core/events/RecurrenceEngineV2.js +7 -1
- package/core/ics/ICSHandler.js +79 -1
- package/core/ics/ICSParser.js +22 -0
- package/core/performance/AdaptiveMemoryManager.js +5 -13
- package/core/state/StateManager.js +55 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/forceCalendar/core/actions/workflows/test.yml)
|
|
4
4
|
[](https://github.com/forceCalendar/core/actions/workflows/code-quality.yml)
|
|
5
5
|
[](https://www.npmjs.com/package/@forcecalendar/core)
|
|
6
|
-
[](https://www.npmjs.com/package/@forcecalendar/core)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
|
|
9
9
|
A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce.
|
package/core/events/Event.js
CHANGED
|
@@ -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 || '')
|
|
44
|
-
|
|
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 =
|
|
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
|
/**
|
|
@@ -63,7 +63,10 @@ export class RRuleParser {
|
|
|
63
63
|
break;
|
|
64
64
|
|
|
65
65
|
case 'BYWEEKNO':
|
|
66
|
-
|
|
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)) {
|
package/core/ics/ICSHandler.js
CHANGED
|
@@ -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
|
|
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,
|
package/core/ics/ICSParser.js
CHANGED
|
@@ -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
|
|
123
|
-
// Salesforce Locker Service blocks any reference to process.memoryUsage
|
|
122
|
+
// Node.js environment
|
|
124
123
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
//
|
|
105
|
+
// Deep sanitize to prevent prototype pollution
|
|
106
106
|
if (updates && typeof updates === 'object') {
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|