pulse-js-framework 1.7.3 → 1.7.5

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/utils.js CHANGED
@@ -129,33 +129,104 @@ export function escapeAttribute(value) {
129
129
  .replace(/>/g, '>');
130
130
  }
131
131
 
132
+ /**
133
+ * Pattern to detect event handler attributes (onclick, onerror, etc.)
134
+ * Matches any attribute starting with "on" followed by lowercase letters
135
+ * @private
136
+ */
137
+ const EVENT_HANDLER_PATTERN = /^on[a-z]+$/i;
138
+
139
+ /**
140
+ * Attributes that accept URLs and need sanitization
141
+ * @private
142
+ */
143
+ const URL_ATTRIBUTES = new Set([
144
+ 'href', 'src', 'action', 'formaction', 'data', 'poster',
145
+ 'cite', 'codebase', 'background', 'profile', 'usemap', 'longdesc',
146
+ 'dynsrc', 'lowsrc', 'srcset', 'imagesrcset'
147
+ ]);
148
+
149
+ /**
150
+ * Attributes that can contain raw HTML/JS content
151
+ * @private
152
+ */
153
+ const HTML_CONTENT_ATTRIBUTES = new Set(['srcdoc']);
154
+
132
155
  /**
133
156
  * Safely set an attribute on an element.
134
- * Validates attribute names to prevent attribute injection.
157
+ * Validates the attribute name, blocks dangerous attributes, and sanitizes URLs.
158
+ *
159
+ * Security protections:
160
+ * - Blocks ALL event handler attributes (onclick, onerror, onmouseover, etc.)
161
+ * - Sanitizes URL attributes (href, src, action, etc.) to prevent javascript: XSS
162
+ * - Blocks HTML injection attributes (srcdoc)
135
163
  *
136
164
  * @param {HTMLElement} element - Target element
137
165
  * @param {string} name - Attribute name
138
166
  * @param {*} value - Attribute value
139
- * @returns {boolean} True if attribute was set
167
+ * @param {Object} [options] - Options
168
+ * @param {boolean} [options.allowEventHandlers=false] - Allow event handlers (dangerous!)
169
+ * @param {boolean} [options.allowDataUrls=false] - Allow data: URLs
170
+ * @param {boolean} [options.allowUnsafeHtml=false] - Allow srcdoc attribute (dangerous!)
171
+ * @param {Object} [domAdapter] - Optional DOM adapter (uses element.setAttribute if not provided)
172
+ * @returns {boolean} True if attribute was set successfully
140
173
  *
141
174
  * @example
142
175
  * safeSetAttribute(element, 'data-id', userId);
176
+ * safeSetAttribute(element, 'href', userUrl); // Sanitizes URL automatically
143
177
  */
144
- export function safeSetAttribute(element, name, value) {
178
+ export function safeSetAttribute(element, name, value, options = {}, domAdapter = null) {
179
+ const {
180
+ allowEventHandlers = false,
181
+ allowDataUrls = false,
182
+ allowUnsafeHtml = false
183
+ } = options;
184
+
145
185
  // Validate attribute name (prevent injection attacks)
146
186
  if (!/^[a-zA-Z][a-zA-Z0-9\-_:.]*$/.test(name)) {
147
- console.warn(`Invalid attribute name: ${name}`);
187
+ console.warn(`[Pulse Security] Invalid attribute name blocked: ${name}`);
188
+ return false;
189
+ }
190
+
191
+ const lowerName = name.toLowerCase();
192
+
193
+ // Block ALL event handler attributes (onclick, onerror, onmouseover, etc.)
194
+ if (!allowEventHandlers && EVENT_HANDLER_PATTERN.test(lowerName)) {
195
+ console.warn(
196
+ `[Pulse Security] Event handler attribute blocked: ${name}. ` +
197
+ `Use on(element, '${lowerName.slice(2)}', handler) instead.`
198
+ );
148
199
  return false;
149
200
  }
150
201
 
151
- // Prevent dangerous attributes
152
- const dangerousAttrs = ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus', 'onblur'];
153
- if (dangerousAttrs.includes(name.toLowerCase())) {
154
- console.warn(`Potentially dangerous attribute blocked: ${name}`);
202
+ // Block HTML content attributes that could inject scripts
203
+ if (!allowUnsafeHtml && HTML_CONTENT_ATTRIBUTES.has(lowerName)) {
204
+ console.warn(
205
+ `[Pulse Security] HTML content attribute blocked: ${name}. ` +
206
+ `This attribute can execute arbitrary JavaScript.`
207
+ );
155
208
  return false;
156
209
  }
157
210
 
158
- element.setAttribute(name, value == null ? '' : String(value));
211
+ // Sanitize URL attributes to prevent javascript: XSS
212
+ if (URL_ATTRIBUTES.has(lowerName) && value != null && value !== '') {
213
+ const sanitized = sanitizeUrl(String(value), { allowData: allowDataUrls });
214
+ if (sanitized === null) {
215
+ console.warn(
216
+ `[Pulse Security] Dangerous URL blocked for ${name}: "${String(value).slice(0, 50)}${String(value).length > 50 ? '...' : ''}"`
217
+ );
218
+ return false;
219
+ }
220
+ value = sanitized;
221
+ }
222
+
223
+ // Use adapter if provided, otherwise direct call
224
+ const attrValue = value == null ? '' : String(value);
225
+ if (domAdapter) {
226
+ domAdapter.setAttribute(element, name, attrValue);
227
+ } else {
228
+ element.setAttribute(name, attrValue);
229
+ }
159
230
  return true;
160
231
  }
161
232
 
@@ -166,9 +237,17 @@ export function safeSetAttribute(element, name, value) {
166
237
  /**
167
238
  * Validate and sanitize a URL to prevent javascript: and data: XSS.
168
239
  *
240
+ * Security protections:
241
+ * - Blocks javascript: URLs (including encoded variants)
242
+ * - Blocks data: URLs by default (can be enabled)
243
+ * - Blocks blob: URLs by default (can be enabled)
244
+ * - Blocks vbscript: URLs
245
+ * - Decodes URL before checking to prevent encoding bypass
246
+ *
169
247
  * @param {string} url - URL to validate
170
248
  * @param {Object} [options] - Validation options
171
249
  * @param {boolean} [options.allowData=false] - Allow data: URLs
250
+ * @param {boolean} [options.allowBlob=false] - Allow blob: URLs
172
251
  * @param {boolean} [options.allowRelative=true] - Allow relative URLs
173
252
  * @returns {string|null} Sanitized URL or null if invalid
174
253
  *
@@ -179,26 +258,58 @@ export function safeSetAttribute(element, name, value) {
179
258
  * }
180
259
  */
181
260
  export function sanitizeUrl(url, options = {}) {
182
- const { allowData = false, allowRelative = true } = options;
261
+ const { allowData = false, allowBlob = false, allowRelative = true } = options;
183
262
 
184
263
  if (url == null || url === '') return null;
185
264
 
186
265
  const trimmed = String(url).trim();
187
266
 
188
- // Check for javascript: protocol (case insensitive, handles encoding)
189
- const lowerUrl = trimmed.toLowerCase().replace(/\s/g, '');
190
- if (lowerUrl.startsWith('javascript:')) {
191
- return null;
267
+ // Decode URL to catch encoded attacks like javascript:
268
+ // Also handles %6A%61%76%61%73%63%72%69%70%74 encoding
269
+ let decoded = trimmed;
270
+ try {
271
+ // Decode HTML entities first (j -> j)
272
+ decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
273
+ decoded = decoded.replace(/&#(\d+);?/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)));
274
+ // Then decode URI encoding (%6A -> j)
275
+ decoded = decodeURIComponent(decoded);
276
+ } catch {
277
+ // If decoding fails, use original (malformed URLs will be blocked anyway)
278
+ }
279
+
280
+ // Normalize: lowercase and remove whitespace for protocol check
281
+ const normalized = decoded.toLowerCase().replace(/[\s\x00-\x1f]/g, '');
282
+
283
+ // Block dangerous protocols
284
+ const dangerousProtocols = ['javascript:', 'vbscript:', 'file:'];
285
+ for (const protocol of dangerousProtocols) {
286
+ if (normalized.startsWith(protocol)) {
287
+ return null;
288
+ }
192
289
  }
193
290
 
194
291
  // Check for data: protocol
195
- if (lowerUrl.startsWith('data:')) {
292
+ if (normalized.startsWith('data:')) {
196
293
  return allowData ? trimmed : null;
197
294
  }
198
295
 
199
- // Allow relative URLs
200
- if (allowRelative && (trimmed.startsWith('/') || trimmed.startsWith('.') || !trimmed.includes(':'))) {
201
- return trimmed;
296
+ // Check for blob: protocol
297
+ if (normalized.startsWith('blob:')) {
298
+ return allowBlob ? trimmed : null;
299
+ }
300
+
301
+ // Allow relative URLs (must start with / or . to prevent //evil.com attacks)
302
+ if (allowRelative) {
303
+ if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
304
+ return trimmed;
305
+ }
306
+ if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
307
+ return trimmed;
308
+ }
309
+ // URLs without protocol that don't start with // are relative
310
+ if (!trimmed.includes(':') && !trimmed.startsWith('//')) {
311
+ return trimmed;
312
+ }
202
313
  }
203
314
 
204
315
  // Only allow http: and https: protocols
@@ -209,6 +320,152 @@ export function sanitizeUrl(url, options = {}) {
209
320
  return null;
210
321
  }
211
322
 
323
+ // ============================================================================
324
+ // CSS Sanitization
325
+ // ============================================================================
326
+
327
+ /**
328
+ * Pattern to validate CSS property names (camelCase or kebab-case)
329
+ * @private
330
+ */
331
+ const CSS_PROPERTY_PATTERN = /^-?[a-zA-Z][a-zA-Z0-9-]*$/;
332
+
333
+ /**
334
+ * Dangerous patterns in CSS values that could be used for injection
335
+ * @private
336
+ */
337
+ const CSS_DANGEROUS_PATTERNS = [
338
+ /url\s*\(/i, // url() can make external requests
339
+ /expression\s*\(/i, // IE expression() executes JS
340
+ /@import/i, // @import can load external stylesheets
341
+ /<\/style/i, // Attempt to break out of style context
342
+ /javascript:/i, // javascript: in url()
343
+ /behavior\s*:/i, // IE behavior property
344
+ /-moz-binding/i, // Firefox XBL binding (deprecated but dangerous)
345
+ ];
346
+
347
+ /**
348
+ * Validate a CSS property name.
349
+ *
350
+ * @param {string} prop - CSS property name (camelCase or kebab-case)
351
+ * @returns {boolean} True if valid property name
352
+ *
353
+ * @example
354
+ * isValidCSSProperty('backgroundColor') // true
355
+ * isValidCSSProperty('font-size') // true
356
+ * isValidCSSProperty('123invalid') // false
357
+ */
358
+ export function isValidCSSProperty(prop) {
359
+ if (typeof prop !== 'string' || prop === '') return false;
360
+ return CSS_PROPERTY_PATTERN.test(prop);
361
+ }
362
+
363
+ /**
364
+ * Sanitize a CSS value to prevent injection attacks.
365
+ *
366
+ * Security protections:
367
+ * - Blocks url() which can make external requests (data exfiltration)
368
+ * - Blocks expression() (IE JavaScript execution)
369
+ * - Blocks @import (external stylesheet loading)
370
+ * - Blocks attempts to break out of style context
371
+ * - Removes semicolons to prevent property injection
372
+ *
373
+ * @param {*} value - CSS value to sanitize
374
+ * @param {Object} [options] - Options
375
+ * @param {boolean} [options.allowUrl=false] - Allow url() values
376
+ * @param {boolean} [options.allowMultiple=false] - Allow semicolons (multiple properties)
377
+ * @returns {{safe: boolean, value: string, blocked?: string}} Sanitization result
378
+ *
379
+ * @example
380
+ * sanitizeCSSValue('red') // { safe: true, value: 'red' }
381
+ * sanitizeCSSValue('red; margin: 999px') // { safe: false, value: 'red', blocked: 'semicolon' }
382
+ * sanitizeCSSValue('url(http://evil.com)') // { safe: false, value: '', blocked: 'url()' }
383
+ */
384
+ export function sanitizeCSSValue(value, options = {}) {
385
+ const { allowUrl = false, allowMultiple = false } = options;
386
+
387
+ if (value == null) {
388
+ return { safe: true, value: '' };
389
+ }
390
+
391
+ let strValue = String(value);
392
+
393
+ // Check for dangerous patterns
394
+ for (const pattern of CSS_DANGEROUS_PATTERNS) {
395
+ if (pattern.test(strValue)) {
396
+ // url() can be allowed with option
397
+ if (pattern.source.includes('url') && allowUrl) {
398
+ continue;
399
+ }
400
+ const patternName = pattern.source.replace(/\\s\*|\\|\/i|\(|\)/g, '');
401
+ return { safe: false, value: '', blocked: patternName };
402
+ }
403
+ }
404
+
405
+ // Check for semicolons (property injection)
406
+ if (!allowMultiple && strValue.includes(';')) {
407
+ // Remove everything after the semicolon
408
+ const sanitized = strValue.split(';')[0].trim();
409
+ return { safe: false, value: sanitized, blocked: 'semicolon' };
410
+ }
411
+
412
+ // Check for curly braces (rule injection)
413
+ if (strValue.includes('{') || strValue.includes('}')) {
414
+ return { safe: false, value: '', blocked: 'braces' };
415
+ }
416
+
417
+ return { safe: true, value: strValue };
418
+ }
419
+
420
+ /**
421
+ * Safely set a CSS style property on an element.
422
+ *
423
+ * @param {HTMLElement} element - Target element
424
+ * @param {string} prop - CSS property name
425
+ * @param {*} value - CSS value
426
+ * @param {Object} [options] - Options passed to sanitizeCSSValue
427
+ * @param {Object} [domAdapter] - Optional DOM adapter (uses element.style if not provided)
428
+ * @returns {boolean} True if style was set successfully
429
+ *
430
+ * @example
431
+ * safeSetStyle(element, 'color', 'red'); // true
432
+ * safeSetStyle(element, 'color', 'red; margin: 0'); // false (blocked)
433
+ */
434
+ export function safeSetStyle(element, prop, value, options = {}, domAdapter = null) {
435
+ // Validate property name
436
+ if (!isValidCSSProperty(prop)) {
437
+ console.warn(`[Pulse Security] Invalid CSS property name: ${prop}`);
438
+ return false;
439
+ }
440
+
441
+ // Sanitize value
442
+ const result = sanitizeCSSValue(value, options);
443
+
444
+ // Helper to set style
445
+ const setStyle = (p, v) => {
446
+ if (domAdapter) {
447
+ domAdapter.setStyle(element, p, v);
448
+ } else {
449
+ element.style[p] = v;
450
+ }
451
+ };
452
+
453
+ if (!result.safe) {
454
+ console.warn(
455
+ `[Pulse Security] CSS injection blocked for ${prop}: ${result.blocked}. ` +
456
+ `Original value: "${String(value).slice(0, 50)}${String(value).length > 50 ? '...' : ''}"`
457
+ );
458
+ // Still set the sanitized portion if available
459
+ if (result.value) {
460
+ setStyle(prop, result.value);
461
+ }
462
+ return false;
463
+ }
464
+
465
+ setStyle(prop, result.value);
466
+ return true;
467
+ }
468
+
212
469
  // ============================================================================
213
470
  // Deep Clone
214
471
  // ============================================================================
@@ -341,6 +598,10 @@ export default {
341
598
  escapeAttribute,
342
599
  safeSetAttribute,
343
600
  sanitizeUrl,
601
+ // CSS Sanitization
602
+ isValidCSSProperty,
603
+ sanitizeCSSValue,
604
+ safeSetStyle,
344
605
  // Utilities
345
606
  deepClone,
346
607
  debounce,