pulse-js-framework 1.7.2 → 1.7.3

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/runtime/router.js CHANGED
@@ -23,6 +23,9 @@ const log = loggers.router;
23
23
  * Lazy load helper for route components
24
24
  * Wraps a dynamic import to provide loading states and error handling
25
25
  *
26
+ * MEMORY SAFETY: Uses load version tracking to prevent stale promise callbacks
27
+ * from updating containers that are no longer in the DOM (e.g., after navigation).
28
+ *
26
29
  * @param {function} importFn - Dynamic import function () => import('./Component.js')
27
30
  * @param {Object} options - Lazy loading options
28
31
  * @param {function} options.loading - Loading component function
@@ -52,6 +55,8 @@ export function lazy(importFn, options = {}) {
52
55
  // Cache for loaded component
53
56
  let cachedComponent = null;
54
57
  let loadPromise = null;
58
+ // Version counter to invalidate stale load callbacks
59
+ let currentLoadVersion = 0;
55
60
 
56
61
  return function lazyHandler(ctx) {
57
62
  // Return cached component if already loaded
@@ -69,6 +74,23 @@ export function lazy(importFn, options = {}) {
69
74
  const container = el('div.lazy-route');
70
75
  let loadingTimer = null;
71
76
  let timeoutTimer = null;
77
+ let isAborted = false;
78
+
79
+ // Increment version and capture for this load attempt
80
+ const loadVersion = ++currentLoadVersion;
81
+
82
+ // Helper to check if this load is still valid
83
+ const isStale = () => isAborted || loadVersion !== currentLoadVersion;
84
+
85
+ // Cleanup function to abort this load attempt
86
+ const abort = () => {
87
+ isAborted = true;
88
+ clearTimeout(loadingTimer);
89
+ clearTimeout(timeoutTimer);
90
+ };
91
+
92
+ // Attach abort method to container for cleanup on navigation
93
+ container._pulseAbortLazyLoad = abort;
72
94
 
73
95
  // Start loading if not already
74
96
  if (!loadPromise) {
@@ -78,7 +100,7 @@ export function lazy(importFn, options = {}) {
78
100
  // Delay showing loading state to avoid flash
79
101
  if (LoadingComponent && delay > 0) {
80
102
  loadingTimer = setTimeout(() => {
81
- if (!cachedComponent) {
103
+ if (!cachedComponent && !isStale()) {
82
104
  container.replaceChildren(LoadingComponent());
83
105
  }
84
106
  }, delay);
@@ -105,6 +127,11 @@ export function lazy(importFn, options = {}) {
105
127
  clearTimeout(loadingTimer);
106
128
  clearTimeout(timeoutTimer);
107
129
 
130
+ // Ignore if this load attempt is stale (navigation occurred)
131
+ if (isStale()) {
132
+ return;
133
+ }
134
+
108
135
  // Cache the component
109
136
  cachedComponent = module;
110
137
 
@@ -117,7 +144,7 @@ export function lazy(importFn, options = {}) {
117
144
  : Component;
118
145
 
119
146
  // Replace loading with actual component
120
- if (result instanceof Node) {
147
+ if (result instanceof Node && !isStale()) {
121
148
  container.replaceChildren(result);
122
149
  }
123
150
  })
@@ -126,6 +153,11 @@ export function lazy(importFn, options = {}) {
126
153
  clearTimeout(timeoutTimer);
127
154
  loadPromise = null; // Allow retry
128
155
 
156
+ // Ignore if this load attempt is stale
157
+ if (isStale()) {
158
+ return;
159
+ }
160
+
129
161
  if (ErrorComponent) {
130
162
  container.replaceChildren(ErrorComponent(err));
131
163
  } else {
@@ -432,6 +464,12 @@ const QUERY_LIMITS = {
432
464
 
433
465
  /**
434
466
  * Parse query string into object with validation
467
+ *
468
+ * SECURITY: Enforces hard limits BEFORE parsing to prevent DoS attacks.
469
+ * - Max total length: 2KB
470
+ * - Max value length: 1KB
471
+ * - Max parameters: 50
472
+ *
435
473
  * @param {string} search - Query string (with or without leading ?)
436
474
  * @returns {Object} Parsed query parameters
437
475
  */
@@ -439,14 +477,15 @@ function parseQuery(search) {
439
477
  if (!search) return {};
440
478
 
441
479
  // Remove leading ? if present
442
- const queryStr = search.startsWith('?') ? search.slice(1) : search;
480
+ let queryStr = search.startsWith('?') ? search.slice(1) : search;
443
481
 
444
- // Validate total length
482
+ // SECURITY: Enforce hard limit BEFORE parsing to prevent DoS
445
483
  if (queryStr.length > QUERY_LIMITS.maxTotalLength) {
446
484
  log.warn(`Query string exceeds maximum length (${QUERY_LIMITS.maxTotalLength} chars). Truncating.`);
485
+ queryStr = queryStr.slice(0, QUERY_LIMITS.maxTotalLength);
447
486
  }
448
487
 
449
- const params = new URLSearchParams(queryStr.slice(0, QUERY_LIMITS.maxTotalLength));
488
+ const params = new URLSearchParams(queryStr);
450
489
  const query = {};
451
490
  let paramCount = 0;
452
491
 
@@ -808,6 +847,9 @@ export function createRouter(options = {}) {
808
847
 
809
848
  /**
810
849
  * Router outlet - renders the current route's component
850
+ *
851
+ * MEMORY SAFETY: Aborts any pending lazy loads when navigating away
852
+ * to prevent stale callbacks from updating the DOM.
811
853
  */
812
854
  function outlet(container) {
813
855
  if (typeof container === 'string') {
@@ -825,6 +867,10 @@ export function createRouter(options = {}) {
825
867
  // Cleanup previous view
826
868
  if (cleanup) cleanup();
827
869
  if (currentView) {
870
+ // Abort any pending lazy loads before removing the view
871
+ if (currentView._pulseAbortLazyLoad) {
872
+ currentView._pulseAbortLazyLoad();
873
+ }
828
874
  container.replaceChildren();
829
875
  }
830
876
 
package/runtime/store.js CHANGED
@@ -108,9 +108,51 @@ function validateStateValue(value, path = 'state', seen = new WeakSet()) {
108
108
  }
109
109
  }
110
110
 
111
+ /**
112
+ * Recursively sanitize a value, removing dangerous keys from nested objects.
113
+ * @private
114
+ * @param {*} value - Value to sanitize
115
+ * @param {number} depth - Current nesting depth
116
+ * @returns {*} Sanitized value
117
+ */
118
+ function sanitizeValue(value, depth = 0) {
119
+ // Prevent deep nesting attacks
120
+ if (depth > MAX_NESTING_DEPTH) {
121
+ log.warn('Maximum nesting depth exceeded in persisted state');
122
+ return null;
123
+ }
124
+
125
+ if (value === null || typeof value !== 'object') {
126
+ return value;
127
+ }
128
+
129
+ if (Array.isArray(value)) {
130
+ // Recursively sanitize array elements
131
+ return value.map(item => sanitizeValue(item, depth + 1));
132
+ }
133
+
134
+ // Sanitize object
135
+ const result = {};
136
+ for (const [key, val] of Object.entries(value)) {
137
+ // Block dangerous keys at every nesting level
138
+ if (DANGEROUS_KEYS.has(key)) {
139
+ log.warn(`Blocked dangerous key in persisted state: "${key}"`);
140
+ continue;
141
+ }
142
+ result[key] = sanitizeValue(val, depth + 1);
143
+ }
144
+ return result;
145
+ }
146
+
111
147
  /**
112
148
  * Safely deserialize persisted state, preventing prototype pollution
113
149
  * and property injection attacks.
150
+ *
151
+ * SECURITY: Validates at every nesting level including arrays.
152
+ * - Blocks __proto__, constructor, prototype keys
153
+ * - Enforces maximum nesting depth
154
+ * - Only allows keys defined in schema
155
+ *
114
156
  * @private
115
157
  * @param {Object} savedState - The parsed JSON state
116
158
  * @param {Object} schema - The initial state defining allowed keys
@@ -140,6 +182,9 @@ function safeDeserialize(savedState, schema) {
140
182
  result[key] = safeDeserialize(value, schema[key]);
141
183
  }
142
184
  // If schema expects primitive but got object, skip it
185
+ } else if (Array.isArray(value)) {
186
+ // Sanitize arrays to remove dangerous keys from nested objects
187
+ result[key] = sanitizeValue(value, 0);
143
188
  } else {
144
189
  result[key] = value;
145
190
  }