@bquery/bquery 1.0.0

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.
Files changed (80) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +266 -0
  3. package/dist/component/index.d.ts +155 -0
  4. package/dist/component/index.d.ts.map +1 -0
  5. package/dist/component.es.mjs +128 -0
  6. package/dist/component.es.mjs.map +1 -0
  7. package/dist/core/collection.d.ts +198 -0
  8. package/dist/core/collection.d.ts.map +1 -0
  9. package/dist/core/element.d.ts +301 -0
  10. package/dist/core/element.d.ts.map +1 -0
  11. package/dist/core/index.d.ts +5 -0
  12. package/dist/core/index.d.ts.map +1 -0
  13. package/dist/core/selector.d.ts +11 -0
  14. package/dist/core/selector.d.ts.map +1 -0
  15. package/dist/core/shared.d.ts +7 -0
  16. package/dist/core/shared.d.ts.map +1 -0
  17. package/dist/core/utils.d.ts +300 -0
  18. package/dist/core/utils.d.ts.map +1 -0
  19. package/dist/core.es.mjs +1015 -0
  20. package/dist/core.es.mjs.map +1 -0
  21. package/dist/full.d.ts +48 -0
  22. package/dist/full.d.ts.map +1 -0
  23. package/dist/full.es.mjs +43 -0
  24. package/dist/full.es.mjs.map +1 -0
  25. package/dist/full.iife.js +2 -0
  26. package/dist/full.iife.js.map +1 -0
  27. package/dist/full.umd.js +2 -0
  28. package/dist/full.umd.js.map +1 -0
  29. package/dist/index.d.ts +16 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.es.mjs +43 -0
  32. package/dist/index.es.mjs.map +1 -0
  33. package/dist/motion/index.d.ts +145 -0
  34. package/dist/motion/index.d.ts.map +1 -0
  35. package/dist/motion.es.mjs +104 -0
  36. package/dist/motion.es.mjs.map +1 -0
  37. package/dist/platform/buckets.d.ts +44 -0
  38. package/dist/platform/buckets.d.ts.map +1 -0
  39. package/dist/platform/cache.d.ts +71 -0
  40. package/dist/platform/cache.d.ts.map +1 -0
  41. package/dist/platform/index.d.ts +15 -0
  42. package/dist/platform/index.d.ts.map +1 -0
  43. package/dist/platform/notifications.d.ts +52 -0
  44. package/dist/platform/notifications.d.ts.map +1 -0
  45. package/dist/platform/storage.d.ts +69 -0
  46. package/dist/platform/storage.d.ts.map +1 -0
  47. package/dist/platform.es.mjs +245 -0
  48. package/dist/platform.es.mjs.map +1 -0
  49. package/dist/reactive/index.d.ts +8 -0
  50. package/dist/reactive/index.d.ts.map +1 -0
  51. package/dist/reactive/signal.d.ts +204 -0
  52. package/dist/reactive/signal.d.ts.map +1 -0
  53. package/dist/reactive.es.mjs +123 -0
  54. package/dist/reactive.es.mjs.map +1 -0
  55. package/dist/security/index.d.ts +8 -0
  56. package/dist/security/index.d.ts.map +1 -0
  57. package/dist/security/sanitize.d.ts +99 -0
  58. package/dist/security/sanitize.d.ts.map +1 -0
  59. package/dist/security.es.mjs +194 -0
  60. package/dist/security.es.mjs.map +1 -0
  61. package/package.json +120 -0
  62. package/src/component/index.ts +360 -0
  63. package/src/core/collection.ts +339 -0
  64. package/src/core/element.ts +493 -0
  65. package/src/core/index.ts +4 -0
  66. package/src/core/selector.ts +29 -0
  67. package/src/core/shared.ts +13 -0
  68. package/src/core/utils.ts +425 -0
  69. package/src/full.ts +101 -0
  70. package/src/index.ts +27 -0
  71. package/src/motion/index.ts +365 -0
  72. package/src/platform/buckets.ts +115 -0
  73. package/src/platform/cache.ts +130 -0
  74. package/src/platform/index.ts +18 -0
  75. package/src/platform/notifications.ts +87 -0
  76. package/src/platform/storage.ts +208 -0
  77. package/src/reactive/index.ts +9 -0
  78. package/src/reactive/signal.ts +347 -0
  79. package/src/security/index.ts +18 -0
  80. package/src/security/sanitize.ts +446 -0
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Security utilities for HTML sanitization, CSP compatibility, and Trusted Types.
3
+ * All DOM writes are sanitized by default to prevent XSS attacks.
4
+ *
5
+ * @module bquery/security
6
+ */
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Sanitizer configuration options.
14
+ */
15
+ export interface SanitizeOptions {
16
+ /** Allow these additional tags (default: none) */
17
+ allowTags?: string[];
18
+ /** Allow these additional attributes (default: none) */
19
+ allowAttributes?: string[];
20
+ /** Allow data-* attributes (default: true) */
21
+ allowDataAttributes?: boolean;
22
+ /** Strip all tags and return plain text (default: false) */
23
+ stripAllTags?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Trusted Types policy name.
28
+ */
29
+ const POLICY_NAME = 'bquery-sanitizer';
30
+
31
+ // ============================================================================
32
+ // Trusted Types Support
33
+ // ============================================================================
34
+
35
+ /** Window interface extended with Trusted Types */
36
+ interface TrustedTypesWindow extends Window {
37
+ trustedTypes?: {
38
+ createPolicy: (
39
+ name: string,
40
+ rules: { createHTML?: (input: string) => string }
41
+ ) => TrustedTypePolicy;
42
+ isHTML?: (value: unknown) => boolean;
43
+ };
44
+ }
45
+
46
+ /** Trusted Types policy interface */
47
+ interface TrustedTypePolicy {
48
+ createHTML: (input: string) => TrustedHTML;
49
+ }
50
+
51
+ /** Trusted HTML type placeholder for environments without Trusted Types */
52
+ interface TrustedHTML {
53
+ toString(): string;
54
+ }
55
+
56
+ /** Cached Trusted Types policy */
57
+ let cachedPolicy: TrustedTypePolicy | null = null;
58
+
59
+ /**
60
+ * Check if Trusted Types API is available.
61
+ * @returns True if Trusted Types are supported
62
+ */
63
+ export const isTrustedTypesSupported = (): boolean => {
64
+ return typeof (window as TrustedTypesWindow).trustedTypes !== 'undefined';
65
+ };
66
+
67
+ /**
68
+ * Get or create the bQuery Trusted Types policy.
69
+ * @returns The Trusted Types policy or null if unsupported
70
+ */
71
+ export const getTrustedTypesPolicy = (): TrustedTypePolicy | null => {
72
+ if (cachedPolicy) return cachedPolicy;
73
+
74
+ const win = window as TrustedTypesWindow;
75
+ if (!win.trustedTypes) return null;
76
+
77
+ try {
78
+ cachedPolicy = win.trustedTypes.createPolicy(POLICY_NAME, {
79
+ createHTML: (input: string) => sanitizeHtmlCore(input),
80
+ });
81
+ return cachedPolicy;
82
+ } catch {
83
+ // Policy may already exist or be blocked by CSP
84
+ console.warn(`bQuery: Could not create Trusted Types policy "${POLICY_NAME}"`);
85
+ return null;
86
+ }
87
+ };
88
+
89
+ // ============================================================================
90
+ // Default Safe Lists
91
+ // ============================================================================
92
+
93
+ /**
94
+ * Default allowed HTML tags considered safe.
95
+ */
96
+ const DEFAULT_ALLOWED_TAGS = new Set([
97
+ 'a',
98
+ 'abbr',
99
+ 'address',
100
+ 'article',
101
+ 'aside',
102
+ 'b',
103
+ 'bdi',
104
+ 'bdo',
105
+ 'blockquote',
106
+ 'br',
107
+ 'button',
108
+ 'caption',
109
+ 'cite',
110
+ 'code',
111
+ 'col',
112
+ 'colgroup',
113
+ 'data',
114
+ 'dd',
115
+ 'del',
116
+ 'details',
117
+ 'dfn',
118
+ 'div',
119
+ 'dl',
120
+ 'dt',
121
+ 'em',
122
+ 'figcaption',
123
+ 'figure',
124
+ 'footer',
125
+ 'form',
126
+ 'h1',
127
+ 'h2',
128
+ 'h3',
129
+ 'h4',
130
+ 'h5',
131
+ 'h6',
132
+ 'header',
133
+ 'hgroup',
134
+ 'hr',
135
+ 'i',
136
+ 'img',
137
+ 'input',
138
+ 'ins',
139
+ 'kbd',
140
+ 'label',
141
+ 'legend',
142
+ 'li',
143
+ 'main',
144
+ 'mark',
145
+ 'nav',
146
+ 'ol',
147
+ 'optgroup',
148
+ 'option',
149
+ 'p',
150
+ 'picture',
151
+ 'pre',
152
+ 'progress',
153
+ 'q',
154
+ 'rp',
155
+ 'rt',
156
+ 'ruby',
157
+ 's',
158
+ 'samp',
159
+ 'section',
160
+ 'select',
161
+ 'small',
162
+ 'source',
163
+ 'span',
164
+ 'strong',
165
+ 'sub',
166
+ 'summary',
167
+ 'sup',
168
+ 'table',
169
+ 'tbody',
170
+ 'td',
171
+ 'textarea',
172
+ 'tfoot',
173
+ 'th',
174
+ 'thead',
175
+ 'time',
176
+ 'tr',
177
+ 'u',
178
+ 'ul',
179
+ 'var',
180
+ 'wbr',
181
+ ]);
182
+
183
+ /**
184
+ * Default allowed attributes considered safe.
185
+ */
186
+ const DEFAULT_ALLOWED_ATTRIBUTES = new Set([
187
+ 'alt',
188
+ 'class',
189
+ 'dir',
190
+ 'height',
191
+ 'hidden',
192
+ 'href',
193
+ 'id',
194
+ 'lang',
195
+ 'loading',
196
+ 'name',
197
+ 'role',
198
+ 'src',
199
+ 'srcset',
200
+ 'style',
201
+ 'tabindex',
202
+ 'title',
203
+ 'type',
204
+ 'width',
205
+ 'aria-*',
206
+ ]);
207
+
208
+ /**
209
+ * Dangerous attribute prefixes to always remove.
210
+ */
211
+ const DANGEROUS_ATTR_PREFIXES = ['on', 'formaction'];
212
+
213
+ /**
214
+ * Dangerous URL protocols to block.
215
+ */
216
+ const DANGEROUS_PROTOCOLS = ['javascript:', 'data:', 'vbscript:'];
217
+
218
+ // ============================================================================
219
+ // Core Sanitization
220
+ // ============================================================================
221
+
222
+ /**
223
+ * Check if an attribute name is allowed.
224
+ */
225
+ const isAllowedAttribute = (
226
+ name: string,
227
+ allowedSet: Set<string>,
228
+ allowDataAttrs: boolean
229
+ ): boolean => {
230
+ const lowerName = name.toLowerCase();
231
+
232
+ // Check dangerous prefixes
233
+ for (const prefix of DANGEROUS_ATTR_PREFIXES) {
234
+ if (lowerName.startsWith(prefix)) return false;
235
+ }
236
+
237
+ // Check data attributes
238
+ if (allowDataAttrs && lowerName.startsWith('data-')) return true;
239
+
240
+ // Check aria attributes (allowed by default)
241
+ if (lowerName.startsWith('aria-')) return true;
242
+
243
+ // Check explicit allow list
244
+ return allowedSet.has(lowerName);
245
+ };
246
+
247
+ /**
248
+ * Normalize URL by removing control characters and whitespace.
249
+ */
250
+ const normalizeUrl = (value: string): string =>
251
+ value.replace(/[\u0000-\u001F\u007F\s]+/g, '').toLowerCase();
252
+
253
+ /**
254
+ * Check if a URL value is safe.
255
+ */
256
+ const isSafeUrl = (value: string): boolean => {
257
+ const normalized = normalizeUrl(value);
258
+ for (const protocol of DANGEROUS_PROTOCOLS) {
259
+ if (normalized.startsWith(protocol)) return false;
260
+ }
261
+ return true;
262
+ };
263
+
264
+ /**
265
+ * Core sanitization logic (without Trusted Types wrapper).
266
+ */
267
+ const sanitizeHtmlCore = (html: string, options: SanitizeOptions = {}): string => {
268
+ const {
269
+ allowTags = [],
270
+ allowAttributes = [],
271
+ allowDataAttributes = true,
272
+ stripAllTags = false,
273
+ } = options;
274
+
275
+ // Build combined allow sets
276
+ const allowedTags = new Set([...DEFAULT_ALLOWED_TAGS, ...allowTags.map((t) => t.toLowerCase())]);
277
+ const allowedAttrs = new Set([
278
+ ...DEFAULT_ALLOWED_ATTRIBUTES,
279
+ ...allowAttributes.map((a) => a.toLowerCase()),
280
+ ]);
281
+
282
+ // Use template for parsing
283
+ const template = document.createElement('template');
284
+ template.innerHTML = html;
285
+
286
+ if (stripAllTags) {
287
+ return template.content.textContent ?? '';
288
+ }
289
+
290
+ // Walk the DOM tree
291
+ const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT);
292
+
293
+ const toRemove: Element[] = [];
294
+
295
+ while (walker.nextNode()) {
296
+ const el = walker.currentNode as Element;
297
+ const tagName = el.tagName.toLowerCase();
298
+
299
+ // Remove disallowed tags entirely
300
+ if (!allowedTags.has(tagName)) {
301
+ toRemove.push(el);
302
+ continue;
303
+ }
304
+
305
+ // Process attributes
306
+ const attrsToRemove: string[] = [];
307
+ for (const attr of Array.from(el.attributes)) {
308
+ const attrName = attr.name.toLowerCase();
309
+
310
+ // Check if attribute is allowed
311
+ if (!isAllowedAttribute(attrName, allowedAttrs, allowDataAttributes)) {
312
+ attrsToRemove.push(attr.name);
313
+ continue;
314
+ }
315
+
316
+ // Validate URL attributes
317
+ if (
318
+ (attrName === 'href' || attrName === 'src' || attrName === 'srcset') &&
319
+ !isSafeUrl(attr.value)
320
+ ) {
321
+ attrsToRemove.push(attr.name);
322
+ }
323
+ }
324
+
325
+ // Remove disallowed attributes
326
+ for (const attrName of attrsToRemove) {
327
+ el.removeAttribute(attrName);
328
+ }
329
+ }
330
+
331
+ // Remove disallowed elements
332
+ for (const el of toRemove) {
333
+ el.remove();
334
+ }
335
+
336
+ return template.innerHTML;
337
+ };
338
+
339
+ // ============================================================================
340
+ // Public API
341
+ // ============================================================================
342
+
343
+ /**
344
+ * Sanitize HTML string, removing dangerous elements and attributes.
345
+ * Uses Trusted Types when available for CSP compliance.
346
+ *
347
+ * @param html - The HTML string to sanitize
348
+ * @param options - Sanitization options
349
+ * @returns Sanitized HTML string
350
+ *
351
+ * @example
352
+ * ```ts
353
+ * const safe = sanitizeHtml('<div onclick="alert(1)">Hello</div>');
354
+ * // Returns: '<div>Hello</div>'
355
+ * ```
356
+ */
357
+ export const sanitizeHtml = (html: string, options: SanitizeOptions = {}): string => {
358
+ return sanitizeHtmlCore(html, options);
359
+ };
360
+
361
+ /**
362
+ * Create a Trusted HTML value for use with Trusted Types-enabled sites.
363
+ * Falls back to regular string when Trusted Types are unavailable.
364
+ *
365
+ * @param html - The HTML string to wrap
366
+ * @returns Trusted HTML value or sanitized string
367
+ */
368
+ export const createTrustedHtml = (html: string): TrustedHTML | string => {
369
+ const policy = getTrustedTypesPolicy();
370
+ if (policy) {
371
+ return policy.createHTML(html);
372
+ }
373
+ return sanitizeHtml(html);
374
+ };
375
+
376
+ /**
377
+ * Escape HTML entities to prevent XSS.
378
+ * Use this for displaying user content as text.
379
+ *
380
+ * @param text - The text to escape
381
+ * @returns Escaped HTML string
382
+ *
383
+ * @example
384
+ * ```ts
385
+ * escapeHtml('<script>alert(1)</script>');
386
+ * // Returns: '&lt;script&gt;alert(1)&lt;/script&gt;'
387
+ * ```
388
+ */
389
+ export const escapeHtml = (text: string): string => {
390
+ const escapeMap: Record<string, string> = {
391
+ '&': '&amp;',
392
+ '<': '&lt;',
393
+ '>': '&gt;',
394
+ '"': '&quot;',
395
+ "'": '&#x27;',
396
+ '`': '&#x60;',
397
+ };
398
+ return text.replace(/[&<>"'`]/g, (char) => escapeMap[char]);
399
+ };
400
+
401
+ /**
402
+ * Strip all HTML tags and return plain text.
403
+ *
404
+ * @param html - The HTML string to strip
405
+ * @returns Plain text content
406
+ */
407
+ export const stripTags = (html: string): string => {
408
+ return sanitizeHtmlCore(html, { stripAllTags: true });
409
+ };
410
+
411
+ // ============================================================================
412
+ // CSP Helpers
413
+ // ============================================================================
414
+
415
+ /**
416
+ * Generate a nonce for inline scripts/styles.
417
+ * Use with Content-Security-Policy nonce directives.
418
+ *
419
+ * @param length - Nonce length (default: 16)
420
+ * @returns Cryptographically random nonce string
421
+ */
422
+ export const generateNonce = (length: number = 16): string => {
423
+ const array = new Uint8Array(length);
424
+ crypto.getRandomValues(array);
425
+ return btoa(String.fromCharCode(...array))
426
+ .replace(/\+/g, '-')
427
+ .replace(/\//g, '_')
428
+ .replace(/=/g, '');
429
+ };
430
+
431
+ /**
432
+ * Check if a CSP header is present with specific directive.
433
+ * Useful for feature detection and fallback strategies.
434
+ *
435
+ * @param directive - The CSP directive to check (e.g., 'script-src')
436
+ * @returns True if the directive appears to be enforced
437
+ */
438
+ export const hasCSPDirective = (directive: string): boolean => {
439
+ // Check meta tag
440
+ const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
441
+ if (meta) {
442
+ const content = meta.getAttribute('content') ?? '';
443
+ return content.includes(directive);
444
+ }
445
+ return false;
446
+ };