pulse-js-framework 1.7.9 → 1.7.10

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.
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Pulse Security Module
3
+ * @module pulse-js-framework/runtime/security
4
+ *
5
+ * Centralized security utilities and constants for the Pulse framework.
6
+ * Provides protection against:
7
+ * - Prototype pollution
8
+ * - XSS attacks
9
+ * - Injection attacks
10
+ */
11
+
12
+ import { createLogger } from './logger.js';
13
+
14
+ const log = createLogger('Security');
15
+
16
+ // =============================================================================
17
+ // DANGEROUS PROPERTIES
18
+ // =============================================================================
19
+
20
+ /**
21
+ * Properties that could be used for prototype pollution attacks.
22
+ * These should never be accepted as user-provided keys.
23
+ * @type {Set<string>}
24
+ */
25
+ export const DANGEROUS_KEYS = new Set([
26
+ // Prototype chain manipulation
27
+ '__proto__',
28
+ 'constructor',
29
+ 'prototype',
30
+
31
+ // Property descriptor manipulation
32
+ '__defineGetter__',
33
+ '__defineSetter__',
34
+ '__lookupGetter__',
35
+ '__lookupSetter__',
36
+
37
+ // Object prototype methods that could be overwritten
38
+ 'hasOwnProperty',
39
+ 'isPrototypeOf',
40
+ 'propertyIsEnumerable',
41
+ 'toLocaleString',
42
+ 'toString',
43
+ 'valueOf',
44
+
45
+ // Dangerous globals
46
+ 'eval',
47
+ 'Function'
48
+ ]);
49
+
50
+ /**
51
+ * Event handler attributes that could execute JavaScript.
52
+ * @type {Set<string>}
53
+ */
54
+ export const EVENT_HANDLER_ATTRS = new Set([
55
+ 'onabort', 'onanimationcancel', 'onanimationend', 'onanimationiteration',
56
+ 'onanimationstart', 'onauxclick', 'onbeforeinput', 'onblur', 'oncancel',
57
+ 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick', 'onclose',
58
+ 'oncontextmenu', 'oncopy', 'oncuechange', 'oncut', 'ondblclick', 'ondrag',
59
+ 'ondragend', 'ondragenter', 'ondragleave', 'ondragover', 'ondragstart',
60
+ 'ondrop', 'ondurationchange', 'onemptied', 'onended', 'onerror', 'onfocus',
61
+ 'onformdata', 'ongotpointercapture', 'oninput', 'oninvalid', 'onkeydown',
62
+ 'onkeypress', 'onkeyup', 'onload', 'onloadeddata', 'onloadedmetadata',
63
+ 'onloadstart', 'onlostpointercapture', 'onmousedown', 'onmouseenter',
64
+ 'onmouseleave', 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup',
65
+ 'onpaste', 'onpause', 'onplay', 'onplaying', 'onpointercancel',
66
+ 'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove',
67
+ 'onpointerout', 'onpointerover', 'onpointerup', 'onprogress', 'onratechange',
68
+ 'onreset', 'onresize', 'onscroll', 'onsecuritypolicyviolation', 'onseeked',
69
+ 'onseeking', 'onselect', 'onselectionchange', 'onselectstart', 'onslotchange',
70
+ 'onstalled', 'onsubmit', 'onsuspend', 'ontimeupdate', 'ontoggle',
71
+ 'ontouchcancel', 'ontouchend', 'ontouchmove', 'ontouchstart',
72
+ 'ontransitioncancel', 'ontransitionend', 'ontransitionrun', 'ontransitionstart',
73
+ 'onvolumechange', 'onwaiting', 'onwebkitanimationend', 'onwebkitanimationiteration',
74
+ 'onwebkitanimationstart', 'onwebkittransitionend', 'onwheel'
75
+ ]);
76
+
77
+ /**
78
+ * Dangerous URL protocols that could execute JavaScript.
79
+ * @type {Set<string>}
80
+ */
81
+ export const DANGEROUS_PROTOCOLS = new Set([
82
+ 'javascript:',
83
+ 'vbscript:',
84
+ 'data:', // Can contain JavaScript in some contexts
85
+ 'blob:' // Can contain JavaScript in some contexts
86
+ ]);
87
+
88
+ /**
89
+ * Safe URL protocols.
90
+ * @type {Set<string>}
91
+ */
92
+ export const SAFE_PROTOCOLS = new Set([
93
+ 'http:',
94
+ 'https:',
95
+ 'mailto:',
96
+ 'tel:',
97
+ 'sms:',
98
+ 'ftp:',
99
+ 'sftp:'
100
+ ]);
101
+
102
+ // =============================================================================
103
+ // VALIDATION FUNCTIONS
104
+ // =============================================================================
105
+
106
+ /**
107
+ * Check if a key is potentially dangerous (prototype pollution risk).
108
+ *
109
+ * @param {string} key - The key to check
110
+ * @returns {boolean} True if the key is dangerous
111
+ *
112
+ * @example
113
+ * if (isDangerousKey(userProvidedKey)) {
114
+ * throw new Error('Invalid key');
115
+ * }
116
+ */
117
+ export function isDangerousKey(key) {
118
+ return DANGEROUS_KEYS.has(key);
119
+ }
120
+
121
+ /**
122
+ * Validate and filter an object's keys to prevent prototype pollution.
123
+ *
124
+ * @param {Object} obj - Object to validate
125
+ * @param {Object} [options={}] - Options
126
+ * @param {boolean} [options.throwOnDangerous=false] - Throw error instead of filtering
127
+ * @param {boolean} [options.logWarnings=true] - Log warnings for filtered keys
128
+ * @returns {Object} Cleaned object with dangerous keys removed
129
+ *
130
+ * @example
131
+ * const safeData = sanitizeObjectKeys(userInput);
132
+ */
133
+ export function sanitizeObjectKeys(obj, options = {}) {
134
+ const { throwOnDangerous = false, logWarnings = true } = options;
135
+
136
+ if (obj === null || typeof obj !== 'object') {
137
+ return obj;
138
+ }
139
+
140
+ const result = Array.isArray(obj) ? [] : {};
141
+
142
+ for (const key of Object.keys(obj)) {
143
+ if (isDangerousKey(key)) {
144
+ if (throwOnDangerous) {
145
+ throw new Error(`Dangerous key blocked: ${key}`);
146
+ }
147
+ if (logWarnings) {
148
+ log.warn(`Dangerous key filtered: ${key}`);
149
+ }
150
+ continue;
151
+ }
152
+
153
+ const value = obj[key];
154
+ if (value !== null && typeof value === 'object') {
155
+ result[key] = sanitizeObjectKeys(value, options);
156
+ } else {
157
+ result[key] = value;
158
+ }
159
+ }
160
+
161
+ return result;
162
+ }
163
+
164
+ // =============================================================================
165
+ // HTML SANITIZATION
166
+ // =============================================================================
167
+
168
+ /**
169
+ * HTML entity escape map
170
+ * @private
171
+ */
172
+ const HTML_ESCAPES = {
173
+ '&': '&amp;',
174
+ '<': '&lt;',
175
+ '>': '&gt;',
176
+ '"': '&quot;',
177
+ "'": '&#39;'
178
+ };
179
+
180
+ /**
181
+ * Escape HTML special characters to prevent XSS.
182
+ *
183
+ * @param {string} str - String to escape
184
+ * @returns {string} Escaped string safe for HTML insertion
185
+ *
186
+ * @example
187
+ * const safe = escapeHtml('<script>alert("xss")</script>');
188
+ * // Returns: '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
189
+ */
190
+ export function escapeHtml(str) {
191
+ if (str == null) return '';
192
+ return String(str).replace(/[&<>"']/g, char => HTML_ESCAPES[char]);
193
+ }
194
+
195
+ /**
196
+ * Tags allowed by default in sanitized HTML.
197
+ * @type {Set<string>}
198
+ */
199
+ export const DEFAULT_ALLOWED_TAGS = new Set([
200
+ // Text formatting
201
+ 'p', 'br', 'hr', 'span', 'div',
202
+ 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'del', 'ins',
203
+ 'sub', 'sup', 'small', 'mark', 'abbr', 'cite', 'code', 'pre',
204
+ 'blockquote', 'q',
205
+
206
+ // Headings
207
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
208
+
209
+ // Lists
210
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
211
+
212
+ // Tables
213
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
214
+
215
+ // Links and media (sanitized)
216
+ 'a', 'img',
217
+
218
+ // Semantic
219
+ 'article', 'section', 'nav', 'aside', 'header', 'footer', 'main',
220
+ 'figure', 'figcaption', 'time', 'address'
221
+ ]);
222
+
223
+ /**
224
+ * Attributes allowed by default in sanitized HTML.
225
+ * @type {Set<string>}
226
+ */
227
+ export const DEFAULT_ALLOWED_ATTRS = new Set([
228
+ // Global attributes
229
+ 'id', 'class', 'title', 'lang', 'dir',
230
+
231
+ // Links
232
+ 'href', 'target', 'rel',
233
+
234
+ // Images
235
+ 'src', 'alt', 'width', 'height', 'loading',
236
+
237
+ // Tables
238
+ 'colspan', 'rowspan', 'scope',
239
+
240
+ // Accessibility
241
+ 'role', 'aria-label', 'aria-labelledby', 'aria-describedby',
242
+ 'aria-hidden', 'aria-expanded', 'aria-selected', 'aria-checked',
243
+ 'tabindex'
244
+ ]);
245
+
246
+ /**
247
+ * Sanitize HTML string to remove potentially dangerous content.
248
+ *
249
+ * This is a basic sanitizer. For production use with untrusted content,
250
+ * consider using a dedicated library like DOMPurify.
251
+ *
252
+ * @param {string} html - HTML string to sanitize
253
+ * @param {Object} [options={}] - Sanitization options
254
+ * @param {Set<string>} [options.allowedTags] - Tags to allow
255
+ * @param {Set<string>} [options.allowedAttrs] - Attributes to allow
256
+ * @param {boolean} [options.allowDataUrls=false] - Allow data: URLs in src/href
257
+ * @returns {string} Sanitized HTML string
258
+ *
259
+ * @example
260
+ * const safe = sanitizeHtml('<script>alert("xss")</script><p>Hello</p>');
261
+ * // Returns: '<p>Hello</p>'
262
+ */
263
+ export function sanitizeHtml(html, options = {}) {
264
+ if (!html || typeof html !== 'string') return '';
265
+
266
+ const {
267
+ allowedTags = DEFAULT_ALLOWED_TAGS,
268
+ allowedAttrs = DEFAULT_ALLOWED_ATTRS,
269
+ allowDataUrls = false
270
+ } = options;
271
+
272
+ // Use browser's DOMParser for safe parsing
273
+ if (typeof DOMParser === 'undefined') {
274
+ // Fallback for non-browser environments: strip all tags
275
+ return html.replace(/<[^>]*>/g, '');
276
+ }
277
+
278
+ const parser = new DOMParser();
279
+ const doc = parser.parseFromString(html, 'text/html');
280
+
281
+ function sanitizeNode(node) {
282
+ if (node.nodeType === 3) { // Text node
283
+ return node.textContent;
284
+ }
285
+
286
+ if (node.nodeType !== 1) { // Not an element
287
+ return '';
288
+ }
289
+
290
+ const tagName = node.tagName.toLowerCase();
291
+
292
+ // Remove disallowed tags entirely
293
+ if (!allowedTags.has(tagName)) {
294
+ // Still process children for some tags
295
+ let childContent = '';
296
+ for (const child of node.childNodes) {
297
+ childContent += sanitizeNode(child);
298
+ }
299
+ return childContent;
300
+ }
301
+
302
+ // Build sanitized element
303
+ let result = `<${tagName}`;
304
+
305
+ // Sanitize attributes
306
+ for (const attr of node.attributes) {
307
+ const attrName = attr.name.toLowerCase();
308
+
309
+ // Skip disallowed attributes
310
+ if (!allowedAttrs.has(attrName)) continue;
311
+
312
+ // Skip event handlers
313
+ if (attrName.startsWith('on')) continue;
314
+
315
+ let attrValue = attr.value;
316
+
317
+ // Sanitize URLs
318
+ if (attrName === 'href' || attrName === 'src') {
319
+ const sanitized = sanitizeUrl(attrValue, { allowData: allowDataUrls });
320
+ if (!sanitized) continue;
321
+ attrValue = sanitized;
322
+ }
323
+
324
+ result += ` ${attrName}="${escapeHtml(attrValue)}"`;
325
+ }
326
+
327
+ // Self-closing tags
328
+ const selfClosing = new Set(['br', 'hr', 'img', 'input', 'meta', 'link']);
329
+ if (selfClosing.has(tagName)) {
330
+ return result + ' />';
331
+ }
332
+
333
+ result += '>';
334
+
335
+ // Process children
336
+ for (const child of node.childNodes) {
337
+ result += sanitizeNode(child);
338
+ }
339
+
340
+ result += `</${tagName}>`;
341
+ return result;
342
+ }
343
+
344
+ let output = '';
345
+ for (const child of doc.body.childNodes) {
346
+ output += sanitizeNode(child);
347
+ }
348
+
349
+ return output;
350
+ }
351
+
352
+ // =============================================================================
353
+ // URL SANITIZATION
354
+ // =============================================================================
355
+
356
+ /**
357
+ * Sanitize a URL to prevent JavaScript execution.
358
+ *
359
+ * @param {string} url - URL to sanitize
360
+ * @param {Object} [options={}] - Options
361
+ * @param {boolean} [options.allowData=false] - Allow data: URLs
362
+ * @param {boolean} [options.allowBlob=false] - Allow blob: URLs
363
+ * @param {boolean} [options.allowRelative=true] - Allow relative URLs
364
+ * @returns {string|null} Sanitized URL or null if dangerous
365
+ *
366
+ * @example
367
+ * const safe = sanitizeUrl('javascript:alert("xss")');
368
+ * // Returns: null
369
+ *
370
+ * const safe2 = sanitizeUrl('https://example.com');
371
+ * // Returns: 'https://example.com'
372
+ */
373
+ export function sanitizeUrl(url, options = {}) {
374
+ const { allowData = false, allowBlob = false, allowRelative = true } = options;
375
+
376
+ if (url == null || url === '') return null;
377
+
378
+ const trimmed = String(url).trim();
379
+
380
+ // Decode URL to catch encoded attacks
381
+ let decoded = trimmed;
382
+ try {
383
+ // Decode HTML entities first
384
+ decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) =>
385
+ String.fromCharCode(parseInt(hex, 16))
386
+ );
387
+ decoded = decoded.replace(/&#(\d+);?/g, (_, dec) =>
388
+ String.fromCharCode(parseInt(dec, 10))
389
+ );
390
+ // Then decode URI encoding
391
+ decoded = decodeURIComponent(decoded);
392
+ } catch {
393
+ // Malformed URL - use original
394
+ }
395
+
396
+ // Normalize and check protocol
397
+ const normalized = decoded.toLowerCase().replace(/[\s\x00-\x1f]/g, '');
398
+
399
+ // Block javascript: protocol
400
+ if (normalized.startsWith('javascript:')) {
401
+ log.warn('Blocked javascript: URL');
402
+ return null;
403
+ }
404
+
405
+ // Block vbscript: protocol
406
+ if (normalized.startsWith('vbscript:')) {
407
+ log.warn('Blocked vbscript: URL');
408
+ return null;
409
+ }
410
+
411
+ // Check data: URLs
412
+ if (normalized.startsWith('data:')) {
413
+ if (!allowData) {
414
+ log.warn('Blocked data: URL (not allowed)');
415
+ return null;
416
+ }
417
+ // Even when allowed, block data:text/html which can contain scripts
418
+ if (normalized.includes('text/html') || normalized.includes('text/javascript')) {
419
+ log.warn('Blocked dangerous data: URL');
420
+ return null;
421
+ }
422
+ }
423
+
424
+ // Check blob: URLs
425
+ if (normalized.startsWith('blob:') && !allowBlob) {
426
+ log.warn('Blocked blob: URL (not allowed)');
427
+ return null;
428
+ }
429
+
430
+ // Check for relative URLs
431
+ if (!trimmed.includes(':')) {
432
+ return allowRelative ? trimmed : null;
433
+ }
434
+
435
+ return trimmed;
436
+ }
437
+
438
+ // =============================================================================
439
+ // EXPORTS
440
+ // =============================================================================
441
+
442
+ export default {
443
+ // Constants
444
+ DANGEROUS_KEYS,
445
+ EVENT_HANDLER_ATTRS,
446
+ DANGEROUS_PROTOCOLS,
447
+ SAFE_PROTOCOLS,
448
+ DEFAULT_ALLOWED_TAGS,
449
+ DEFAULT_ALLOWED_ATTRS,
450
+
451
+ // Validation
452
+ isDangerousKey,
453
+ sanitizeObjectKeys,
454
+
455
+ // HTML
456
+ escapeHtml,
457
+ sanitizeHtml,
458
+
459
+ // URL
460
+ sanitizeUrl
461
+ };
package/runtime/utils.js CHANGED
@@ -5,6 +5,10 @@
5
5
  * Common utility functions for the Pulse framework runtime.
6
6
  */
7
7
 
8
+ import { createLogger } from './logger.js';
9
+
10
+ const log = createLogger('Security');
11
+
8
12
  // ============================================================================
9
13
  // XSS Prevention
10
14
  // ============================================================================
@@ -68,22 +72,39 @@ export function unescapeHtml(str) {
68
72
  }
69
73
 
70
74
  /**
71
- * Explicitly set innerHTML with a warning.
75
+ * Explicitly set innerHTML with optional sanitization.
72
76
  * This function is intentionally named to make it obvious that
73
77
  * it's potentially dangerous and bypasses XSS protection.
74
78
  *
75
79
  * SECURITY WARNING: Only use this with trusted, sanitized HTML.
76
- * Never use with user-provided content without proper sanitization.
80
+ * Never use with user-provided content without enabling sanitization.
77
81
  *
78
82
  * @param {HTMLElement} element - Target element
79
83
  * @param {string} html - HTML string to insert
84
+ * @param {Object} [options={}] - Options
85
+ * @param {boolean} [options.sanitize=false] - Enable HTML sanitization
86
+ * @param {Set<string>} [options.allowedTags] - Custom allowed tags (requires sanitize=true)
87
+ * @param {Set<string>} [options.allowedAttrs] - Custom allowed attributes (requires sanitize=true)
88
+ * @param {boolean} [options.allowDataUrls=false] - Allow data: URLs (requires sanitize=true)
80
89
  *
81
90
  * @example
82
- * // Only use with trusted HTML!
83
- * dangerouslySetInnerHTML(container, sanitizedHtml);
91
+ * // Trusted HTML (no sanitization)
92
+ * dangerouslySetInnerHTML(container, trustedHtml);
93
+ *
94
+ * // Untrusted HTML (with sanitization)
95
+ * dangerouslySetInnerHTML(container, userHtml, { sanitize: true });
84
96
  */
85
- export function dangerouslySetInnerHTML(element, html) {
86
- element.innerHTML = html;
97
+ export function dangerouslySetInnerHTML(element, html, options = {}) {
98
+ const { sanitize = false, ...sanitizeOptions } = options;
99
+
100
+ if (sanitize) {
101
+ // Import sanitizeHtml from security module
102
+ import('./security.js').then(({ sanitizeHtml }) => {
103
+ element.innerHTML = sanitizeHtml(html, sanitizeOptions);
104
+ });
105
+ } else {
106
+ element.innerHTML = html;
107
+ }
87
108
  }
88
109
 
89
110
  /**
@@ -184,7 +205,7 @@ export function safeSetAttribute(element, name, value, options = {}, domAdapter
184
205
 
185
206
  // Validate attribute name (prevent injection attacks)
186
207
  if (!/^[a-zA-Z][a-zA-Z0-9\-_:.]*$/.test(name)) {
187
- console.warn(`[Pulse Security] Invalid attribute name blocked: ${name}`);
208
+ log.warn(`Invalid attribute name blocked: ${name}`);
188
209
  return false;
189
210
  }
190
211
 
@@ -192,8 +213,8 @@ export function safeSetAttribute(element, name, value, options = {}, domAdapter
192
213
 
193
214
  // Block ALL event handler attributes (onclick, onerror, onmouseover, etc.)
194
215
  if (!allowEventHandlers && EVENT_HANDLER_PATTERN.test(lowerName)) {
195
- console.warn(
196
- `[Pulse Security] Event handler attribute blocked: ${name}. ` +
216
+ log.warn(
217
+ `Event handler attribute blocked: ${name}. ` +
197
218
  `Use on(element, '${lowerName.slice(2)}', handler) instead.`
198
219
  );
199
220
  return false;
@@ -201,8 +222,8 @@ export function safeSetAttribute(element, name, value, options = {}, domAdapter
201
222
 
202
223
  // Block HTML content attributes that could inject scripts
203
224
  if (!allowUnsafeHtml && HTML_CONTENT_ATTRIBUTES.has(lowerName)) {
204
- console.warn(
205
- `[Pulse Security] HTML content attribute blocked: ${name}. ` +
225
+ log.warn(
226
+ `HTML content attribute blocked: ${name}. ` +
206
227
  `This attribute can execute arbitrary JavaScript.`
207
228
  );
208
229
  return false;
@@ -212,8 +233,8 @@ export function safeSetAttribute(element, name, value, options = {}, domAdapter
212
233
  if (URL_ATTRIBUTES.has(lowerName) && value != null && value !== '') {
213
234
  const sanitized = sanitizeUrl(String(value), { allowData: allowDataUrls });
214
235
  if (sanitized === null) {
215
- console.warn(
216
- `[Pulse Security] Dangerous URL blocked for ${name}: "${String(value).slice(0, 50)}${String(value).length > 50 ? '...' : ''}"`
236
+ log.warn(
237
+ `Dangerous URL blocked for ${name}: "${String(value).slice(0, 50)}${String(value).length > 50 ? '...' : ''}"`
217
238
  );
218
239
  return false;
219
240
  }
@@ -434,7 +455,7 @@ export function sanitizeCSSValue(value, options = {}) {
434
455
  export function safeSetStyle(element, prop, value, options = {}, domAdapter = null) {
435
456
  // Validate property name
436
457
  if (!isValidCSSProperty(prop)) {
437
- console.warn(`[Pulse Security] Invalid CSS property name: ${prop}`);
458
+ log.warn(`Invalid CSS property name: ${prop}`);
438
459
  return false;
439
460
  }
440
461
 
@@ -451,8 +472,8 @@ export function safeSetStyle(element, prop, value, options = {}, domAdapter = nu
451
472
  };
452
473
 
453
474
  if (!result.safe) {
454
- console.warn(
455
- `[Pulse Security] CSS injection blocked for ${prop}: ${result.blocked}. ` +
475
+ log.warn(
476
+ `CSS injection blocked for ${prop}: ${result.blocked}. ` +
456
477
  `Original value: "${String(value).slice(0, 50)}${String(value).length > 50 ? '...' : ''}"`
457
478
  );
458
479
  // Still set the sanitized portion if available