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/cli/analyze.js +127 -46
- package/cli/build.js +148 -34
- package/cli/dev.js +20 -5
- package/cli/format.js +64 -8
- package/cli/lint.js +112 -27
- package/cli/utils/cli-ui.js +452 -0
- package/compiler/parser.js +19 -2
- package/core/errors.js +281 -6
- package/package.json +7 -2
- package/runtime/async.js +282 -14
- package/runtime/dom-adapter.js +920 -0
- package/runtime/dom.js +331 -162
- package/runtime/logger.js +144 -69
- package/runtime/logger.prod.js +43 -18
- package/runtime/pulse.js +202 -80
- package/runtime/router.js +27 -39
- package/runtime/store.js +10 -7
- package/runtime/utils.js +279 -18
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
|
|
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
|
-
* @
|
|
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
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 (
|
|
292
|
+
if (normalized.startsWith('data:')) {
|
|
196
293
|
return allowData ? trimmed : null;
|
|
197
294
|
}
|
|
198
295
|
|
|
199
|
-
//
|
|
200
|
-
if (
|
|
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,
|