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.
- package/cli/lint.js +442 -3
- package/compiler/lexer.js +6 -0
- package/compiler/parser.js +144 -1
- package/compiler/transformer/imports.js +15 -0
- package/compiler/transformer/index.js +46 -0
- package/compiler/transformer/view.js +180 -5
- package/package.json +9 -2
- package/runtime/a11y.js +1005 -0
- package/runtime/devtools/a11y-audit.js +442 -0
- package/runtime/devtools/diagnostics.js +403 -0
- package/runtime/devtools/index.js +53 -0
- package/runtime/devtools/time-travel.js +189 -0
- package/runtime/devtools.js +138 -497
- package/runtime/dom-binding.js +7 -4
- package/runtime/dom-element.js +192 -1
- package/runtime/dom.js +8 -2
- package/runtime/index.js +2 -0
- package/runtime/native.js +2 -2
- package/runtime/security.js +461 -0
- package/runtime/utils.js +37 -16
- package/types/a11y.d.ts +336 -0
|
@@ -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
|
+
'&': '&',
|
|
174
|
+
'<': '<',
|
|
175
|
+
'>': '>',
|
|
176
|
+
'"': '"',
|
|
177
|
+
"'": '''
|
|
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: '<script>alert("xss")</script>'
|
|
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
|
|
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
|
|
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
|
-
* //
|
|
83
|
-
* dangerouslySetInnerHTML(container,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
`
|
|
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
|
-
|
|
205
|
-
`
|
|
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
|
-
|
|
216
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
`
|
|
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
|