@designofadecade/server 4.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 (84) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -0
  4. package/dist/client/ApiClient.d.ts +121 -0
  5. package/dist/client/ApiClient.d.ts.map +1 -0
  6. package/dist/client/ApiClient.js +289 -0
  7. package/dist/client/ApiClient.js.map +1 -0
  8. package/dist/context/Context.d.ts +71 -0
  9. package/dist/context/Context.d.ts.map +1 -0
  10. package/dist/context/Context.js +81 -0
  11. package/dist/context/Context.js.map +1 -0
  12. package/dist/docs/OpenApiGenerator.d.ts +135 -0
  13. package/dist/docs/OpenApiGenerator.d.ts.map +1 -0
  14. package/dist/docs/OpenApiGenerator.js +165 -0
  15. package/dist/docs/OpenApiGenerator.js.map +1 -0
  16. package/dist/events/Events.d.ts +52 -0
  17. package/dist/events/Events.d.ts.map +1 -0
  18. package/dist/events/Events.js +70 -0
  19. package/dist/events/Events.js.map +1 -0
  20. package/dist/events/EventsManager.d.ts +46 -0
  21. package/dist/events/EventsManager.d.ts.map +1 -0
  22. package/dist/events/EventsManager.js +137 -0
  23. package/dist/events/EventsManager.js.map +1 -0
  24. package/dist/index.d.ts +32 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +38 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/local/Local.d.ts +83 -0
  29. package/dist/local/Local.d.ts.map +1 -0
  30. package/dist/local/Local.js +114 -0
  31. package/dist/local/Local.js.map +1 -0
  32. package/dist/logger/Logger.d.ts +365 -0
  33. package/dist/logger/Logger.d.ts.map +1 -0
  34. package/dist/logger/Logger.js +582 -0
  35. package/dist/logger/Logger.js.map +1 -0
  36. package/dist/middleware/RequestLogger.d.ts +62 -0
  37. package/dist/middleware/RequestLogger.d.ts.map +1 -0
  38. package/dist/middleware/RequestLogger.js +71 -0
  39. package/dist/middleware/RequestLogger.js.map +1 -0
  40. package/dist/notifications/Slack.d.ts +19 -0
  41. package/dist/notifications/Slack.d.ts.map +1 -0
  42. package/dist/notifications/Slack.js +55 -0
  43. package/dist/notifications/Slack.js.map +1 -0
  44. package/dist/router/RouteError.d.ts +21 -0
  45. package/dist/router/RouteError.d.ts.map +1 -0
  46. package/dist/router/RouteError.js +31 -0
  47. package/dist/router/RouteError.js.map +1 -0
  48. package/dist/router/Router.d.ts +66 -0
  49. package/dist/router/Router.d.ts.map +1 -0
  50. package/dist/router/Router.js +327 -0
  51. package/dist/router/Router.js.map +1 -0
  52. package/dist/router/Routes.d.ts +30 -0
  53. package/dist/router/Routes.d.ts.map +1 -0
  54. package/dist/router/Routes.js +52 -0
  55. package/dist/router/Routes.js.map +1 -0
  56. package/dist/router/StaticFileHandler.d.ts +44 -0
  57. package/dist/router/StaticFileHandler.d.ts.map +1 -0
  58. package/dist/router/StaticFileHandler.js +148 -0
  59. package/dist/router/StaticFileHandler.js.map +1 -0
  60. package/dist/sanitizer/HtmlSanitizer.d.ts +306 -0
  61. package/dist/sanitizer/HtmlSanitizer.d.ts.map +1 -0
  62. package/dist/sanitizer/HtmlSanitizer.js +808 -0
  63. package/dist/sanitizer/HtmlSanitizer.js.map +1 -0
  64. package/dist/server/Server.d.ts +28 -0
  65. package/dist/server/Server.d.ts.map +1 -0
  66. package/dist/server/Server.js +95 -0
  67. package/dist/server/Server.js.map +1 -0
  68. package/dist/state/AppState.d.ts +64 -0
  69. package/dist/state/AppState.d.ts.map +1 -0
  70. package/dist/state/AppState.js +89 -0
  71. package/dist/state/AppState.js.map +1 -0
  72. package/dist/utils/HtmlRenderer.d.ts +6 -0
  73. package/dist/utils/HtmlRenderer.d.ts.map +1 -0
  74. package/dist/utils/HtmlRenderer.js +128 -0
  75. package/dist/utils/HtmlRenderer.js.map +1 -0
  76. package/dist/websocket/WebSocketMessageFormatter.d.ts +40 -0
  77. package/dist/websocket/WebSocketMessageFormatter.d.ts.map +1 -0
  78. package/dist/websocket/WebSocketMessageFormatter.js +99 -0
  79. package/dist/websocket/WebSocketMessageFormatter.js.map +1 -0
  80. package/dist/websocket/WebSocketServer.d.ts +14 -0
  81. package/dist/websocket/WebSocketServer.d.ts.map +1 -0
  82. package/dist/websocket/WebSocketServer.js +138 -0
  83. package/dist/websocket/WebSocketServer.js.map +1 -0
  84. package/package.json +97 -0
@@ -0,0 +1,808 @@
1
+ /**
2
+ * HTML Sanitization Utility
3
+ *
4
+ * Production-grade HTML sanitization to prevent XSS attacks and ensure safe HTML rendering.
5
+ *
6
+ * Security Features:
7
+ * - XSS Prevention: Blocks dangerous protocols (javascript:, data:, vbscript:, etc.)
8
+ * - DoS Protection: Enforces maximum input size limits
9
+ * - Script/Style Removal: Completely removes script and style tags with content
10
+ * - HTML Comment Stripping: Removes comments that could hide malicious content
11
+ * - URL Validation: Validates and sanitizes URLs in anchor tags
12
+ * - Entity Decoding: Safely decodes HTML entities with range validation
13
+ * - Attribute Escaping: Prevents attribute injection attacks
14
+ *
15
+ * @module HtmlSanitizer
16
+ * @version 2.0.0
17
+ */
18
+ import { logger } from '../logger/Logger.js';
19
+ // ============================================================================
20
+ // Configuration Constants
21
+ // ============================================================================
22
+ /**
23
+ * Maximum input size to prevent DoS attacks (1MB)
24
+ */
25
+ const MAX_INPUT_SIZE = 1024 * 1024;
26
+ /**
27
+ * Valid HTML tag name pattern
28
+ */
29
+ const TAG_NAME_PATTERN = /^[a-z][a-z0-9]*$/i;
30
+ /**
31
+ * Maximum URL length (2048 characters - standard browser limit)
32
+ */
33
+ const MAX_URL_LENGTH = 2048;
34
+ /**
35
+ * Dangerous protocols that could be used for XSS attacks
36
+ */
37
+ const DANGEROUS_PROTOCOLS = /^(javascript:|vbscript:|data:|file:|about:|blob:)/i;
38
+ /**
39
+ * Safe protocols allowed in URLs
40
+ */
41
+ const SAFE_PROTOCOLS = /^(https?:\/\/|mailto:|tel:|sms:|\/|\.\/|\.\.\/|#)/i;
42
+ // ============================================================================
43
+ // HtmlSanitizer Class
44
+ // ============================================================================
45
+ /**
46
+ * HTML Sanitization utility class with static methods for secure HTML processing
47
+ *
48
+ * @class HtmlSanitizer
49
+ */
50
+ export default class HtmlSanitizer {
51
+ // ========================================================================
52
+ // Public API Methods
53
+ // ========================================================================
54
+ /**
55
+ * Cleans HTML by allowing only specified tags and removing all others.
56
+ *
57
+ * Features:
58
+ * - Strips disallowed tags while preserving text content
59
+ * - Validates and sanitizes URLs in anchor tags
60
+ * - Removes HTML comments, script, and style tags
61
+ * - Enforces DoS protection with size limits
62
+ * - Validates allowed tags is a valid array
63
+ *
64
+ * @param html - The HTML string to clean
65
+ * @param allowedTags - Array of allowed tag names (case-insensitive)
66
+ * @returns Sanitized HTML string with only allowed tags
67
+ *
68
+ * @example
69
+ * // Allow only paragraph and bold tags
70
+ * HtmlSanitizer.clean('<p>Hello <b>World</b></p><script>alert("xss")</script>', ['p', 'b']);
71
+ * // Returns: '<p>Hello <b>World</b></p>'
72
+ *
73
+ * @example
74
+ * // Sanitize anchor tags with URL validation
75
+ * HtmlSanitizer.clean('<a href="https://example.com">Safe</a><a href="javascript:alert(1)">Unsafe</a>', ['a']);
76
+ * // Returns: '<a href="https://example.com">Safe</a><a>Unsafe</a>'
77
+ *
78
+ * @example
79
+ * // Allow common formatting tags
80
+ * HtmlSanitizer.clean(userInput, ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li']);
81
+ */
82
+ static clean(html, allowedTags) {
83
+ // ====================================================================
84
+ // Step 1: Input Validation
85
+ // ====================================================================
86
+ if (!html || typeof html !== 'string') {
87
+ return '';
88
+ }
89
+ if (!Array.isArray(allowedTags) || allowedTags.length === 0) {
90
+ logger.warn('No allowed tags provided, stripping all HTML', {
91
+ code: 'SANITIZER_NO_ALLOWED_TAGS',
92
+ source: 'HtmlSanitizer.clean',
93
+ });
94
+ return this.stripAllTags(html);
95
+ }
96
+ // ====================================================================
97
+ // Step 2: Validate and Normalize Allowed Tags
98
+ // ====================================================================
99
+ const validAllowedTags = allowedTags
100
+ .filter((tag) => {
101
+ if (typeof tag !== 'string' || !TAG_NAME_PATTERN.test(tag)) {
102
+ logger.warn('Invalid tag name ignored', {
103
+ code: 'SANITIZER_INVALID_TAG',
104
+ source: 'HtmlSanitizer.clean',
105
+ tag,
106
+ });
107
+ return false;
108
+ }
109
+ return true;
110
+ })
111
+ .map((tag) => tag.toLowerCase());
112
+ if (validAllowedTags.length === 0) {
113
+ logger.warn('No valid allowed tags, stripping all HTML', {
114
+ code: 'SANITIZER_NO_VALID_TAGS',
115
+ source: 'HtmlSanitizer.clean',
116
+ });
117
+ return this.stripAllTags(html);
118
+ }
119
+ // ====================================================================
120
+ // Step 3: DoS Protection
121
+ // ====================================================================
122
+ if (html.length > MAX_INPUT_SIZE) {
123
+ logger.error('Input exceeds maximum size for clean(), truncating', {
124
+ code: 'SANITIZER_INPUT_TOO_LARGE',
125
+ source: 'HtmlSanitizer.clean',
126
+ size: html.length,
127
+ max: MAX_INPUT_SIZE,
128
+ });
129
+ html = html.substring(0, MAX_INPUT_SIZE);
130
+ }
131
+ // ====================================================================
132
+ // Step 4: Remove Dangerous Content
133
+ // ====================================================================
134
+ // Remove HTML comments (could hide malicious content)
135
+ let cleaned = html.replace(/<!--[\s\S]*?-->/g, '');
136
+ // Remove script and style tags entirely (including their content)
137
+ cleaned = cleaned.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '');
138
+ // ====================================================================
139
+ // Step 5: Process HTML Tags
140
+ // ====================================================================
141
+ cleaned = cleaned.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tagName) => {
142
+ const lowerTagName = tagName.toLowerCase();
143
+ // Remove tags not in the allowed list (preserve text content)
144
+ if (!validAllowedTags.includes(lowerTagName)) {
145
+ return '';
146
+ }
147
+ // Handle closing tags - always allow if opening tag is allowed
148
+ if (match.startsWith('</')) {
149
+ return `</${lowerTagName}>`;
150
+ }
151
+ // Detect self-closing tags
152
+ const isSelfClosing = match.endsWith('/>');
153
+ // Special handling for anchor tags - validate URLs
154
+ if (lowerTagName === 'a') {
155
+ const hrefMatch = match.match(/href\s*=\s*["']([^"']*)["']/i);
156
+ if (hrefMatch && hrefMatch[1]) {
157
+ const url = hrefMatch[1].trim();
158
+ // Validate URL for security
159
+ if (this.isValidUrl(url)) {
160
+ // Escape the URL to prevent attribute injection
161
+ const escapedUrl = this.sanitizeForAttribute(url);
162
+ return `<a href="${escapedUrl}">`;
163
+ }
164
+ else {
165
+ // Invalid URL - remove href but keep anchor tag
166
+ logger.warn('Unsafe URL removed from anchor tag', {
167
+ code: 'SANITIZER_UNSAFE_URL',
168
+ source: 'HtmlSanitizer.clean',
169
+ url,
170
+ });
171
+ return '<a>';
172
+ }
173
+ }
174
+ else {
175
+ // No href attribute found
176
+ return '<a>';
177
+ }
178
+ }
179
+ // Handle self-closing tags (br, hr, img)
180
+ if (isSelfClosing && ['br', 'hr', 'img'].includes(lowerTagName)) {
181
+ return `<${lowerTagName} />`;
182
+ }
183
+ // For all other allowed tags, return simple opening tag
184
+ return `<${lowerTagName}>`;
185
+ });
186
+ // ====================================================================
187
+ // Step 6: Final Cleanup
188
+ // ====================================================================
189
+ // Normalize whitespace
190
+ cleaned = cleaned.replace(/\s+/g, ' ').trim();
191
+ return cleaned;
192
+ }
193
+ // ========================================================================
194
+ // Convenience Methods
195
+ // ========================================================================
196
+ /**
197
+ * Sanitizes basic HTML content by allowing only b, i, u, and a tags.
198
+ * Common use case for simple user-generated content.
199
+ *
200
+ * @param html - The HTML string to sanitize
201
+ * @returns Sanitized HTML string with basic formatting
202
+ *
203
+ * @example
204
+ * HtmlSanitizer.sanitizeBasicHtml('<b>Bold</b> and <i>italic</i> text');
205
+ * // Returns: '<b>Bold</b> and <i>italic</i> text'
206
+ */
207
+ static sanitizeBasicHtml(html) {
208
+ return this.clean(html, ['b', 'i', 'u', 'a']);
209
+ }
210
+ /**
211
+ * Replaces or adds attributes to specific HTML tags.
212
+ * Useful for adding email-specific attributes like target="_blank" to links.
213
+ *
214
+ * @param html - The HTML string to process
215
+ * @param tagName - The tag name to target (e.g., 'a')
216
+ * @param attributes - Object with attribute key-value pairs to add/replace
217
+ * @returns HTML with updated tag attributes
218
+ *
219
+ * @example
220
+ * HtmlSanitizer.replaceTagAttributes('<a href="/link">Click</a>', 'a', {
221
+ * target: '_blank',
222
+ * style: 'color: blue;'
223
+ * });
224
+ * // Returns: '<a href="/link" target="_blank" style="color: blue;">Click</a>'
225
+ */
226
+ static replaceTagAttributes(html, tagName, attributes) {
227
+ if (!html || typeof html !== 'string') {
228
+ return '';
229
+ }
230
+ if (!tagName || typeof tagName !== 'string') {
231
+ return html;
232
+ }
233
+ if (!attributes || typeof attributes !== 'object') {
234
+ return html;
235
+ }
236
+ const lowerTagName = tagName.toLowerCase();
237
+ // Build attribute string from the attributes object
238
+ const attributeString = Object.entries(attributes)
239
+ .map(([key, value]) => `${key}="${this.sanitizeForAttribute(value)}"`)
240
+ .join(' ');
241
+ // Replace opening tags of the specified type
242
+ return html.replace(new RegExp(`<${lowerTagName}\\b([^>]*)>`, 'gi'), (_match, existingAttrs) => {
243
+ // Parse existing attributes to preserve href, src, etc.
244
+ const preservedAttrs = [];
245
+ // Extract href, src, and other important attributes
246
+ const hrefMatch = existingAttrs.match(/href\s*=\s*["']([^"']*)["']/i);
247
+ const srcMatch = existingAttrs.match(/src\s*=\s*["']([^"']*)["']/i);
248
+ const altMatch = existingAttrs.match(/alt\s*=\s*["']([^"']*)["']/i);
249
+ if (hrefMatch)
250
+ preservedAttrs.push(`href="${hrefMatch[1]}"`);
251
+ if (srcMatch)
252
+ preservedAttrs.push(`src="${srcMatch[1]}"`);
253
+ if (altMatch)
254
+ preservedAttrs.push(`alt="${altMatch[1]}"`);
255
+ // Combine preserved and new attributes
256
+ const allAttrs = [...preservedAttrs, attributeString].filter(Boolean).join(' ');
257
+ return allAttrs ? `<${lowerTagName} ${allAttrs}>` : `<${lowerTagName}>`;
258
+ });
259
+ }
260
+ /**
261
+ * Strips all HTML tags from content, leaving only plain text.
262
+ * Removes comments, scripts, styles, and decodes HTML entities.
263
+ *
264
+ * @param html - The HTML string to strip
265
+ * @returns Plain text without any HTML tags
266
+ *
267
+ * @example
268
+ * HtmlSanitizer.stripAllTags('<p>Hello <b>World</b></p>');
269
+ * // Returns: 'Hello World'
270
+ *
271
+ * @example
272
+ * HtmlSanitizer.stripAllTags('<script>alert(1)</script>Safe text');
273
+ * // Returns: 'Safe text'
274
+ */
275
+ static stripAllTags(html) {
276
+ if (!html || typeof html !== 'string') {
277
+ return '';
278
+ }
279
+ // DoS protection - reject excessively large inputs
280
+ if (html.length > MAX_INPUT_SIZE) {
281
+ logger.error('Input exceeds maximum size for stripAllTags, truncating', {
282
+ code: 'SANITIZER_STRIP_INPUT_TOO_LARGE',
283
+ source: 'HtmlSanitizer.stripAllTags',
284
+ size: html.length,
285
+ max: MAX_INPUT_SIZE,
286
+ });
287
+ html = html.substring(0, MAX_INPUT_SIZE);
288
+ }
289
+ // Remove HTML comments
290
+ let text = html.replace(/<!--[\s\S]*?-->/g, '');
291
+ // Remove script and style tags with content
292
+ text = text.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '');
293
+ // Remove all HTML tags
294
+ text = text.replace(/<[^>]*>/g, '');
295
+ // Decode HTML entities
296
+ text = this.decodeHtmlEntities(text);
297
+ // Clean up extra whitespace
298
+ text = text.replace(/\s+/g, ' ').trim();
299
+ return text;
300
+ }
301
+ /**
302
+ * Alias for stripAllTags() - strips all HTML tags from content.
303
+ * Provided for convenience and backwards compatibility.
304
+ *
305
+ * @param html - The HTML string to strip
306
+ * @returns Plain text without HTML tags
307
+ * @see stripAllTags
308
+ *
309
+ * @example
310
+ * HtmlSanitizer.stripAll('<div>Content</div>');
311
+ * // Returns: 'Content'
312
+ */
313
+ static stripAll(html) {
314
+ return this.stripAllTags(html);
315
+ }
316
+ // ========================================================================
317
+ // Text Escaping Methods
318
+ // ========================================================================
319
+ /**
320
+ * Sanitizes text for safe use in HTML attributes.
321
+ * Escapes characters that could break out of attribute context or inject code.
322
+ *
323
+ * Escapes: &, ", ', <, >, newlines, carriage returns
324
+ *
325
+ * @param text - The text to sanitize
326
+ * @returns HTML-safe text for use in attributes
327
+ *
328
+ * @example
329
+ * HtmlSanitizer.sanitizeForAttribute('value with "quotes"');
330
+ * // Returns: 'value with &quot;quotes&quot;'
331
+ *
332
+ * @example
333
+ * const value = HtmlSanitizer.sanitizeForAttribute(userInput);
334
+ * // Safe in: <div data-value="${value}">...</div>
335
+ */
336
+ static sanitizeForAttribute(text) {
337
+ if (!text || typeof text !== 'string') {
338
+ return '';
339
+ }
340
+ // DoS protection
341
+ if (text.length > MAX_INPUT_SIZE) {
342
+ logger.error('Attribute value exceeds maximum size, truncating', {
343
+ code: 'SANITIZER_ATTRIBUTE_TOO_LARGE',
344
+ source: 'HtmlSanitizer.sanitizeForAttribute',
345
+ size: text.length,
346
+ max: MAX_INPUT_SIZE,
347
+ });
348
+ text = text.substring(0, MAX_INPUT_SIZE);
349
+ }
350
+ // Order matters: & first to avoid double-escaping
351
+ return text
352
+ .replace(/&/g, '&amp;')
353
+ .replace(/"/g, '&quot;')
354
+ .replace(/'/g, '&#39;')
355
+ .replace(/</g, '&lt;')
356
+ .replace(/>/g, '&gt;')
357
+ .replace(/\n/g, '&#10;') // Newlines could break attributes
358
+ .replace(/\r/g, '&#13;'); // Carriage returns
359
+ }
360
+ /**
361
+ * Sanitizes text for safe display in HTML content.
362
+ * Escapes HTML special characters to prevent XSS attacks.
363
+ *
364
+ * Escapes: &, <, >, ", '
365
+ *
366
+ * @param text - The text to sanitize
367
+ * @returns HTML-safe text that can be inserted into HTML content
368
+ *
369
+ * @example
370
+ * HtmlSanitizer.sanitizeForHtml('<script>alert("xss")</script>');
371
+ * // Returns: '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
372
+ *
373
+ * @example
374
+ * const safeText = HtmlSanitizer.sanitizeForHtml(userInput);
375
+ * // Safe in: <p>${safeText}</p>
376
+ */
377
+ static sanitizeForHtml(text) {
378
+ if (!text || typeof text !== 'string') {
379
+ return '';
380
+ }
381
+ // DoS protection
382
+ if (text.length > MAX_INPUT_SIZE) {
383
+ logger.error('HTML content exceeds maximum size, truncating', {
384
+ code: 'SANITIZER_HTML_TOO_LARGE',
385
+ source: 'HtmlSanitizer.sanitizeForHtml',
386
+ size: text.length,
387
+ max: MAX_INPUT_SIZE,
388
+ });
389
+ text = text.substring(0, MAX_INPUT_SIZE);
390
+ }
391
+ // Order matters: & first to avoid double-escaping
392
+ return text
393
+ .replace(/&/g, '&amp;')
394
+ .replace(/</g, '&lt;')
395
+ .replace(/>/g, '&gt;')
396
+ .replace(/"/g, '&quot;')
397
+ .replace(/'/g, '&#39;');
398
+ }
399
+ // ========================================================================
400
+ // URL Validation
401
+ // ========================================================================
402
+ /**
403
+ * Validates if a URL is safe for use in links.
404
+ * Blocks dangerous protocols and validates against common XSS vectors.
405
+ *
406
+ * Blocked protocols: javascript:, vbscript:, data:, file:, about:, blob:
407
+ * Allowed protocols: https://, http://, mailto:, tel:, sms:, relative paths
408
+ *
409
+ * @param url - The URL to validate
410
+ * @returns True if the URL is considered safe, false otherwise
411
+ *
412
+ * @example
413
+ * HtmlSanitizer.isValidUrl('https://example.com');
414
+ * // Returns: true
415
+ *
416
+ * @example
417
+ * HtmlSanitizer.isValidUrl('javascript:alert(1)');
418
+ * // Returns: false
419
+ *
420
+ * @example
421
+ * HtmlSanitizer.isValidUrl('/relative/path');
422
+ * // Returns: true
423
+ */
424
+ static isValidUrl(url) {
425
+ if (!url || typeof url !== 'string') {
426
+ return false;
427
+ }
428
+ // Remove any leading/trailing whitespace
429
+ url = url.trim();
430
+ // Empty URLs are not valid
431
+ if (url.length === 0) {
432
+ return false;
433
+ }
434
+ // DoS protection - reject excessively long URLs
435
+ if (url.length > MAX_URL_LENGTH) {
436
+ logger.warn('URL exceeds maximum length', {
437
+ code: 'SANITIZER_URL_TOO_LONG',
438
+ source: 'HtmlSanitizer.isValidUrl',
439
+ length: url.length,
440
+ max: MAX_URL_LENGTH,
441
+ });
442
+ return false;
443
+ }
444
+ // Normalize to lowercase for protocol checking
445
+ const urlLower = url.toLowerCase();
446
+ // Check for dangerous protocols first (XSS vectors)
447
+ if (DANGEROUS_PROTOCOLS.test(urlLower)) {
448
+ logger.warn('Dangerous protocol detected in URL', {
449
+ code: 'SANITIZER_DANGEROUS_PROTOCOL',
450
+ source: 'HtmlSanitizer.isValidUrl',
451
+ url,
452
+ });
453
+ return false;
454
+ }
455
+ // Allow safe protocols
456
+ // If it has a protocol, it must be from the safe list
457
+ if (url.includes(':')) {
458
+ const isValid = SAFE_PROTOCOLS.test(url);
459
+ if (!isValid) {
460
+ logger.warn('Unknown/unsafe protocol in URL', {
461
+ code: 'SANITIZER_UNSAFE_PROTOCOL',
462
+ source: 'HtmlSanitizer.isValidUrl',
463
+ url,
464
+ });
465
+ }
466
+ return isValid;
467
+ }
468
+ // Relative URLs without protocols are generally safe,
469
+ // but check for encoded dangerous protocols
470
+ if (url.includes('%')) {
471
+ try {
472
+ const decoded = decodeURIComponent(url);
473
+ if (DANGEROUS_PROTOCOLS.test(decoded)) {
474
+ logger.warn('Encoded dangerous protocol detected', {
475
+ code: 'SANITIZER_ENCODED_DANGEROUS_PROTOCOL',
476
+ source: 'HtmlSanitizer.isValidUrl',
477
+ url,
478
+ });
479
+ return false;
480
+ }
481
+ }
482
+ catch (e) {
483
+ // If decoding fails, reject to be safe
484
+ logger.warn('Failed to decode URL - rejecting', {
485
+ code: 'SANITIZER_URL_DECODE_ERROR',
486
+ source: 'HtmlSanitizer.isValidUrl',
487
+ url,
488
+ error: e,
489
+ });
490
+ return false;
491
+ }
492
+ }
493
+ return true;
494
+ }
495
+ /**
496
+ * Validates if an email address is properly formatted.
497
+ * Performs comprehensive email validation following RFC 5321/5322 standards.
498
+ *
499
+ * Validation Rules:
500
+ * - Maximum length: 254 characters (RFC 5321)
501
+ * - Local part (before @): 1-64 characters, allows a-z, 0-9, . _ + % -
502
+ * - Local part cannot start/end with dot or contain consecutive dots
503
+ * - Domain part (after @): 1-255 characters, requires valid TLD
504
+ * - Domain labels: 1-63 characters each, allows a-z, 0-9, hyphen (not at start/end)
505
+ * - Must contain exactly one @ symbol
506
+ * - Case-insensitive validation
507
+ *
508
+ * Security Features:
509
+ * - DoS protection with length limits
510
+ * - Rejects malformed patterns that could be used for injection
511
+ * - Validates against common email spoofing patterns
512
+ *
513
+ * Note: This performs format validation only. For production use, consider:
514
+ * - DNS MX record verification
515
+ * - Email verification (send confirmation email)
516
+ * - Third-party email validation services
517
+ * - Disposable email detection
518
+ *
519
+ * @param email - The email address to validate
520
+ * @returns True if email format is valid, false otherwise
521
+ *
522
+ * @example
523
+ * HtmlSanitizer.isValidEmail('user@example.com');
524
+ * // Returns: true
525
+ *
526
+ * @example
527
+ * HtmlSanitizer.isValidEmail('user+tag@subdomain.example.co.uk');
528
+ * // Returns: true
529
+ *
530
+ * @example
531
+ * HtmlSanitizer.isValidEmail('invalid.email');
532
+ * // Returns: false
533
+ *
534
+ * @example
535
+ * HtmlSanitizer.isValidEmail('user..name@example.com');
536
+ * // Returns: false (consecutive dots)
537
+ */
538
+ static isValidEmail(email) {
539
+ // ====================================================================
540
+ // Step 1: Basic Input Validation
541
+ // ====================================================================
542
+ if (!email || typeof email !== 'string') {
543
+ return false;
544
+ }
545
+ // Trim whitespace
546
+ email = email.trim();
547
+ if (email.length === 0) {
548
+ return false;
549
+ }
550
+ // DoS protection - RFC 5321 maximum is 254 characters
551
+ const MAX_EMAIL_LENGTH = 254;
552
+ if (email.length > MAX_EMAIL_LENGTH) {
553
+ logger.warn('Email exceeds maximum length', {
554
+ code: 'SANITIZER_EMAIL_TOO_LONG',
555
+ source: 'HtmlSanitizer.isValidEmail',
556
+ length: email.length,
557
+ max: MAX_EMAIL_LENGTH,
558
+ });
559
+ return false;
560
+ }
561
+ // ====================================================================
562
+ // Step 2: Split and Validate Structure
563
+ // ====================================================================
564
+ // Must contain exactly one @ symbol
565
+ const atIndex = email.indexOf('@');
566
+ if (atIndex === -1 || atIndex !== email.lastIndexOf('@')) {
567
+ return false;
568
+ }
569
+ const localPart = email.substring(0, atIndex);
570
+ const domainPart = email.substring(atIndex + 1);
571
+ // ====================================================================
572
+ // Step 3: Validate Local Part (before @)
573
+ // ====================================================================
574
+ // RFC 5321: local part max 64 characters
575
+ if (localPart.length === 0 || localPart.length > 64) {
576
+ return false;
577
+ }
578
+ // Local part cannot start or end with a dot
579
+ if (localPart.startsWith('.') || localPart.endsWith('.')) {
580
+ return false;
581
+ }
582
+ // Local part cannot contain consecutive dots
583
+ if (localPart.includes('..')) {
584
+ return false;
585
+ }
586
+ // Validate local part characters: a-z, 0-9, . _ + % -
587
+ if (!/^[a-zA-Z0-9._+%-]+$/.test(localPart)) {
588
+ return false;
589
+ }
590
+ // ====================================================================
591
+ // Step 4: Validate Domain Part (after @)
592
+ // ====================================================================
593
+ // RFC 5321: domain part max 255 characters
594
+ if (domainPart.length === 0 || domainPart.length > 255) {
595
+ return false;
596
+ }
597
+ // Domain must contain at least one dot (TLD required)
598
+ if (!domainPart.includes('.')) {
599
+ return false;
600
+ }
601
+ // Domain cannot start or end with dot or hyphen
602
+ if (domainPart.startsWith('.') ||
603
+ domainPart.endsWith('.') ||
604
+ domainPart.startsWith('-') ||
605
+ domainPart.endsWith('-')) {
606
+ return false;
607
+ }
608
+ // Domain cannot contain consecutive dots
609
+ if (domainPart.includes('..')) {
610
+ return false;
611
+ }
612
+ // ====================================================================
613
+ // Step 5: Validate Domain Labels
614
+ // ====================================================================
615
+ const domainLabels = domainPart.split('.');
616
+ // Must have at least 2 labels (domain + TLD)
617
+ if (domainLabels.length < 2) {
618
+ return false;
619
+ }
620
+ for (const label of domainLabels) {
621
+ // Each label: 1-63 characters (RFC 1035)
622
+ if (label.length === 0 || label.length > 63) {
623
+ return false;
624
+ }
625
+ // Labels cannot start or end with hyphen
626
+ if (label.startsWith('-') || label.endsWith('-')) {
627
+ return false;
628
+ }
629
+ // Labels can only contain: a-z, 0-9, hyphen
630
+ if (!/^[a-zA-Z0-9-]+$/.test(label)) {
631
+ return false;
632
+ }
633
+ }
634
+ // Validate TLD (last label) - must be at least 2 characters and alphabetic
635
+ const tld = domainLabels[domainLabels.length - 1];
636
+ if (tld.length < 2 || !/^[a-zA-Z]+$/.test(tld)) {
637
+ return false;
638
+ }
639
+ return true;
640
+ }
641
+ // ========================================================================
642
+ // Entity Decoding
643
+ // ========================================================================
644
+ /**
645
+ * Decodes common HTML entities to their character equivalents.
646
+ * Safely handles both named entities and numeric character references.
647
+ *
648
+ * Features:
649
+ * - Decodes common named entities (&amp;, &lt;, &nbsp;, etc.)
650
+ * - Decodes numeric entities (&#65; and &#x41; both -> 'A')
651
+ * - Validates character codes are in valid Unicode range
652
+ * - Excludes control characters (except tab, newline, CR)
653
+ *
654
+ * @param text - Text containing HTML entities
655
+ * @returns Text with entities decoded to characters
656
+ *
657
+ * @example
658
+ * HtmlSanitizer.decodeHtmlEntities('&lt;p&gt;Hello &amp; goodbye&lt;/p&gt;');
659
+ * // Returns: '<p>Hello & goodbye</p>'
660
+ *
661
+ * @example
662
+ * HtmlSanitizer.decodeHtmlEntities('&#65;&#66;&#67;'); // ABC
663
+ * HtmlSanitizer.decodeHtmlEntities('&#x41;&#x42;&#x43;'); // ABC
664
+ */
665
+ static decodeHtmlEntities(text) {
666
+ if (!text || typeof text !== 'string') {
667
+ return '';
668
+ }
669
+ // DoS protection
670
+ if (text.length > MAX_INPUT_SIZE) {
671
+ logger.error('Input exceeds maximum size for entity decoding', {
672
+ code: 'SANITIZER_ENTITY_INPUT_TOO_LARGE',
673
+ source: 'HtmlSanitizer.decodeHtmlEntities',
674
+ size: text.length,
675
+ max: MAX_INPUT_SIZE,
676
+ });
677
+ text = text.substring(0, MAX_INPUT_SIZE);
678
+ }
679
+ // Common HTML entities to decode
680
+ const entities = {
681
+ '&amp;': '&',
682
+ '&lt;': '<',
683
+ '&gt;': '>',
684
+ '&quot;': '"',
685
+ '&#39;': "'",
686
+ '&apos;': "'",
687
+ '&nbsp;': ' ',
688
+ '&copy;': '©',
689
+ '&reg;': '®',
690
+ '&trade;': '™',
691
+ };
692
+ // Replace known named entities
693
+ for (const [entity, char] of Object.entries(entities)) {
694
+ text = text.replace(new RegExp(entity, 'g'), char);
695
+ }
696
+ // Handle numeric entities (decimal) - with safety limits
697
+ text = text.replace(/&#(\d+);/g, (match, dec) => {
698
+ const code = parseInt(dec, 10);
699
+ // Limit to valid Unicode range and exclude control characters (except tab, newline, carriage return)
700
+ if (code > 0 &&
701
+ code <= 0x10ffff &&
702
+ (code >= 32 || code === 9 || code === 10 || code === 13)) {
703
+ try {
704
+ return String.fromCharCode(code);
705
+ }
706
+ catch (e) {
707
+ logger.warn('Invalid character code', {
708
+ code: 'SANITIZER_INVALID_CHAR_CODE',
709
+ source: 'HtmlSanitizer.decodeHtmlEntities',
710
+ charCode: code,
711
+ error: e,
712
+ });
713
+ return match;
714
+ }
715
+ }
716
+ return match;
717
+ });
718
+ // Handle numeric entities (hexadecimal) - with safety limits
719
+ text = text.replace(/&#x([0-9a-f]+);/gi, (match, hex) => {
720
+ const code = parseInt(hex, 16);
721
+ // Limit to valid Unicode range and exclude control characters (except tab, newline, carriage return)
722
+ if (code > 0 &&
723
+ code <= 0x10ffff &&
724
+ (code >= 32 || code === 9 || code === 10 || code === 13)) {
725
+ try {
726
+ return String.fromCharCode(code);
727
+ }
728
+ catch (e) {
729
+ logger.warn('Invalid hex character code', {
730
+ code: 'SANITIZER_INVALID_HEX_CODE',
731
+ source: 'HtmlSanitizer.decodeHtmlEntities',
732
+ hex,
733
+ error: e,
734
+ });
735
+ return match;
736
+ }
737
+ }
738
+ return match;
739
+ });
740
+ return text;
741
+ }
742
+ }
743
+ // ============================================================================
744
+ // Usage Guidelines & Best Practices
745
+ // ============================================================================
746
+ /**
747
+ * USAGE GUIDELINES
748
+ *
749
+ * 1. USER-GENERATED CONTENT
750
+ * Always sanitize user input before storing or displaying:
751
+ * ```typescript
752
+ * // For rich text with limited formatting
753
+ * const sanitized = HtmlSanitizer.clean(userInput, ['p', 'br', 'strong', 'em', 'a']);
754
+ *
755
+ * // For plain text display
756
+ * const plainText = HtmlSanitizer.stripAll(userInput);
757
+ * ```
758
+ *
759
+ * 2. ATTRIBUTE VALUES
760
+ * Always escape text used in HTML attributes:
761
+ * ```typescript
762
+ * const safe = HtmlSanitizer.sanitizeForAttribute(userInput);
763
+ * html = `<div data-value="${safe}">...</div>`;
764
+ * ```
765
+ *
766
+ * 3. HTML CONTENT
767
+ * Escape text to be displayed as-is in HTML:
768
+ * ```typescript
769
+ * const safe = HtmlSanitizer.sanitizeForHtml(userInput);
770
+ * html = `<p>${safe}</p>`;
771
+ * ```
772
+ *
773
+ * 4. ALLOWED TAGS
774
+ * Choose the minimum set of tags needed:
775
+ * - Basic formatting: ['b', 'i', 'u', 'br']
776
+ * - Rich text: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li']
777
+ * - Extended: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3']
778
+ *
779
+ * 5. URL VALIDATION
780
+ * URLs are automatically validated in anchor tags:
781
+ * - Allowed: https://, http://, mailto:, tel:, sms:, relative paths
782
+ * - Blocked: javascript:, data:, vbscript:, file:, blob:, about:
783
+ *
784
+ * 6. PERFORMANCE
785
+ * - Input size limited to 1MB (DoS protection)
786
+ * - Use stripAll() for better performance when HTML not needed
787
+ * - Cache sanitized content when possible
788
+ *
789
+ * SECURITY NOTES
790
+ *
791
+ * - XSS Prevention: All dangerous protocols and scripts are blocked
792
+ * - DoS Protection: Input size limits prevent resource exhaustion
793
+ * - Context-Aware: Different methods for different contexts (attributes vs content)
794
+ * - Defense in Depth: Multiple layers of validation and sanitization
795
+ * - Logging: Security events are logged to console for monitoring
796
+ *
797
+ * LIMITATIONS
798
+ *
799
+ * - Does not validate HTML structure (unclosed tags, nesting rules)
800
+ * - Does not support CSS sanitization
801
+ * - Does not support SVG sanitization
802
+ * - Maximum input size: 1MB
803
+ * - Maximum URL length: 2048 characters
804
+ *
805
+ * @see https://owasp.org/www-community/attacks/xss/
806
+ * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
807
+ */
808
+ //# sourceMappingURL=HtmlSanitizer.js.map