@prosdevlab/experience-sdk-plugins 0.1.0 → 0.1.3

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.
@@ -0,0 +1,412 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { sanitizeHTML } from './sanitize';
3
+
4
+ describe('sanitizeHTML', () => {
5
+ describe('Basic Sanitization', () => {
6
+ it('should allow plain text', () => {
7
+ expect(sanitizeHTML('Hello World')).toBe('Hello World');
8
+ });
9
+
10
+ it('should allow allowed tags', () => {
11
+ expect(sanitizeHTML('<strong>Bold</strong>')).toBe('<strong>Bold</strong>');
12
+ expect(sanitizeHTML('<em>Italic</em>')).toBe('<em>Italic</em>');
13
+ expect(sanitizeHTML('<b>Bold</b>')).toBe('<b>Bold</b>');
14
+ expect(sanitizeHTML('<i>Italic</i>')).toBe('<i>Italic</i>');
15
+ expect(sanitizeHTML('<br />')).toBe('<br />');
16
+ expect(sanitizeHTML('<span>Text</span>')).toBe('<span>Text</span>');
17
+ expect(sanitizeHTML('<p>Paragraph</p>')).toBe('<p>Paragraph</p>');
18
+ });
19
+
20
+ it('should allow nested allowed tags', () => {
21
+ expect(sanitizeHTML('<p><strong>Bold</strong> text</p>')).toBe(
22
+ '<p><strong>Bold</strong> text</p>'
23
+ );
24
+ expect(sanitizeHTML('<span><em>Italic</em> content</span>')).toBe(
25
+ '<span><em>Italic</em> content</span>'
26
+ );
27
+ });
28
+
29
+ it('should allow links with href', () => {
30
+ expect(sanitizeHTML('<a href="/page">Link</a>')).toBe('<a href="/page">Link</a>');
31
+ expect(sanitizeHTML('<a href="https://example.com">Link</a>')).toBe(
32
+ '<a href="https://example.com">Link</a>'
33
+ );
34
+ expect(sanitizeHTML('<a href="http://example.com">Link</a>')).toBe(
35
+ '<a href="http://example.com">Link</a>'
36
+ );
37
+ expect(sanitizeHTML('<a href="mailto:test@example.com">Email</a>')).toBe(
38
+ '<a href="mailto:test@example.com">Email</a>'
39
+ );
40
+ expect(sanitizeHTML('<a href="tel:+1234567890">Call</a>')).toBe(
41
+ '<a href="tel:+1234567890">Call</a>'
42
+ );
43
+ });
44
+
45
+ it('should allow class and style attributes', () => {
46
+ expect(sanitizeHTML('<span class="custom">Text</span>')).toBe(
47
+ '<span class="custom">Text</span>'
48
+ );
49
+ expect(sanitizeHTML('<span style="color: red">Text</span>')).toBe(
50
+ '<span style="color: red">Text</span>'
51
+ );
52
+ expect(sanitizeHTML('<a href="/" class="link" style="text-decoration: none">Link</a>')).toBe(
53
+ '<a href="/" class="link" style="text-decoration: none">Link</a>'
54
+ );
55
+ });
56
+
57
+ it('should handle empty strings', () => {
58
+ expect(sanitizeHTML('')).toBe('');
59
+ });
60
+
61
+ it('should handle text with no HTML', () => {
62
+ expect(sanitizeHTML('Plain text with no tags')).toBe('Plain text with no tags');
63
+ });
64
+ });
65
+
66
+ describe('XSS Prevention - Script Tags', () => {
67
+ it('should strip script tags', () => {
68
+ expect(sanitizeHTML('<script>alert("xss")</script>')).toBe('');
69
+ expect(sanitizeHTML('Hello<script>alert("xss")</script>World')).toBe('HelloWorld');
70
+ });
71
+
72
+ it('should strip script tags with attributes', () => {
73
+ expect(sanitizeHTML('<script src="evil.js"></script>')).toBe('');
74
+ expect(sanitizeHTML('<script type="text/javascript">alert("xss")</script>')).toBe('');
75
+ });
76
+
77
+ it('should strip nested script tags', () => {
78
+ expect(sanitizeHTML('<p><script>alert("xss")</script></p>')).toBe('<p></p>');
79
+ expect(sanitizeHTML('<strong><script>alert("xss")</script></strong>')).toBe(
80
+ '<strong></strong>'
81
+ );
82
+ });
83
+
84
+ it('should strip script tags in mixed content', () => {
85
+ expect(sanitizeHTML('<strong>Safe</strong><script>alert("xss")</script><em>Text</em>')).toBe(
86
+ '<strong>Safe</strong><em>Text</em>'
87
+ );
88
+ });
89
+ });
90
+
91
+ describe('XSS Prevention - Event Handlers', () => {
92
+ it('should strip onclick attributes', () => {
93
+ expect(sanitizeHTML('<a href="/" onclick="alert(\'xss\')">Link</a>')).toBe(
94
+ '<a href="/">Link</a>'
95
+ );
96
+ expect(sanitizeHTML('<span onclick="alert(\'xss\')">Text</span>')).toBe('<span>Text</span>');
97
+ });
98
+
99
+ it('should strip onerror attributes', () => {
100
+ expect(sanitizeHTML('<img src="x" onerror="alert(\'xss\')" />')).toBe('');
101
+ expect(sanitizeHTML('<span onerror="alert(\'xss\')">Text</span>')).toBe('<span>Text</span>');
102
+ });
103
+
104
+ it('should strip onload attributes', () => {
105
+ expect(sanitizeHTML('<span onload="alert(\'xss\')">Text</span>')).toBe('<span>Text</span>');
106
+ });
107
+
108
+ it('should strip onmouseover attributes', () => {
109
+ expect(sanitizeHTML('<span onmouseover="alert(\'xss\')">Text</span>')).toBe(
110
+ '<span>Text</span>'
111
+ );
112
+ });
113
+
114
+ it('should strip all event handler attributes', () => {
115
+ const eventHandlers = [
116
+ 'onclick',
117
+ 'onerror',
118
+ 'onload',
119
+ 'onmouseover',
120
+ 'onmouseout',
121
+ 'onfocus',
122
+ 'onblur',
123
+ 'onchange',
124
+ 'onsubmit',
125
+ 'onkeydown',
126
+ 'onkeypress',
127
+ 'onkeyup',
128
+ ];
129
+
130
+ for (const handler of eventHandlers) {
131
+ expect(sanitizeHTML(`<span ${handler}="alert('xss')">Text</span>`)).toBe(
132
+ '<span>Text</span>'
133
+ );
134
+ }
135
+ });
136
+ });
137
+
138
+ describe('XSS Prevention - Dangerous Tags', () => {
139
+ it('should strip iframe tags', () => {
140
+ expect(sanitizeHTML('<iframe src="evil.com"></iframe>')).toBe('');
141
+ expect(sanitizeHTML('Text<iframe></iframe>More')).toBe('TextMore');
142
+ });
143
+
144
+ it('should strip object tags', () => {
145
+ expect(sanitizeHTML('<object data="evil.swf"></object>')).toBe('');
146
+ });
147
+
148
+ it('should strip embed tags', () => {
149
+ expect(sanitizeHTML('<embed src="evil.swf"></embed>')).toBe('');
150
+ });
151
+
152
+ it('should strip form tags', () => {
153
+ expect(sanitizeHTML('<form action="evil.com"></form>')).toBe('');
154
+ });
155
+
156
+ it('should strip input tags', () => {
157
+ expect(sanitizeHTML('<input type="text" />')).toBe('');
158
+ });
159
+
160
+ it('should strip img tags', () => {
161
+ expect(sanitizeHTML('<img src="x.jpg" />')).toBe('');
162
+ });
163
+
164
+ it('should strip style tags', () => {
165
+ expect(sanitizeHTML('<style>body { color: red; }</style>')).toBe('');
166
+ });
167
+
168
+ it('should strip link tags', () => {
169
+ expect(sanitizeHTML('<link rel="stylesheet" href="evil.css" />')).toBe('');
170
+ });
171
+
172
+ it('should strip meta tags', () => {
173
+ expect(sanitizeHTML('<meta http-equiv="refresh" content="0;url=evil.com" />')).toBe('');
174
+ });
175
+
176
+ it('should strip video tags', () => {
177
+ expect(sanitizeHTML('<video src="evil.mp4"></video>')).toBe('');
178
+ });
179
+
180
+ it('should strip audio tags', () => {
181
+ expect(sanitizeHTML('<audio src="evil.mp3"></audio>')).toBe('');
182
+ });
183
+
184
+ it('should strip svg tags', () => {
185
+ expect(sanitizeHTML('<svg><script>alert("xss")</script></svg>')).toBe('');
186
+ });
187
+ });
188
+
189
+ describe('XSS Prevention - javascript: URLs', () => {
190
+ it('should strip javascript: URLs in href', () => {
191
+ expect(sanitizeHTML('<a href="javascript:alert(\'xss\')">Link</a>')).toBe('<a>Link</a>');
192
+ expect(sanitizeHTML('<a href="JAVASCRIPT:alert(\'xss\')">Link</a>')).toBe('<a>Link</a>');
193
+ expect(sanitizeHTML('<a href="javascript:void(0)">Link</a>')).toBe('<a>Link</a>');
194
+ });
195
+
196
+ it('should strip javascript: URLs with encoded characters', () => {
197
+ expect(sanitizeHTML('<a href="javascript&#58;alert(\'xss\')">Link</a>')).toBe('<a>Link</a>');
198
+ });
199
+ });
200
+
201
+ describe('XSS Prevention - data: URLs', () => {
202
+ it('should strip data: URLs in href', () => {
203
+ expect(
204
+ sanitizeHTML('<a href="data:text/html,<script>alert(\'xss\')</script>">Link</a>')
205
+ ).toBe('<a>Link</a>');
206
+ expect(sanitizeHTML('<a href="data:image/svg+xml,<svg>...</svg>">Link</a>')).toBe(
207
+ '<a>Link</a>'
208
+ );
209
+ });
210
+ });
211
+
212
+ describe('XSS Prevention - Style-based Attacks', () => {
213
+ it('should allow safe style attributes', () => {
214
+ expect(sanitizeHTML('<span style="color: red">Text</span>')).toBe(
215
+ '<span style="color: red">Text</span>'
216
+ );
217
+ });
218
+
219
+ it('should escape HTML in style attributes', () => {
220
+ // Quotes in style attributes are escaped for safety
221
+ const result = sanitizeHTML('<span style="color: \'red\'">Text</span>');
222
+ expect(result).toContain('<span style=');
223
+ expect(result).toContain('Text</span>');
224
+ // The exact escaping format may vary, but quotes should be escaped
225
+ expect(result).not.toContain("color: 'red'");
226
+ });
227
+ });
228
+
229
+ describe('XSS Prevention - HTML Entities', () => {
230
+ it('should escape HTML entities in text content', () => {
231
+ expect(sanitizeHTML('<strong>&lt;script&gt;</strong>')).toBe(
232
+ '<strong>&lt;script&gt;</strong>'
233
+ );
234
+ expect(sanitizeHTML('&lt;script&gt;alert("xss")&lt;/script&gt;')).toBe(
235
+ '&lt;script&gt;alert("xss")&lt;/script&gt;'
236
+ );
237
+ });
238
+
239
+ it('should escape quotes in attributes', () => {
240
+ expect(sanitizeHTML('<a href="/page" title=\'test"quote\'>Link</a>')).toBe(
241
+ '<a href="/page" title="test&quot;quote">Link</a>'
242
+ );
243
+ });
244
+ });
245
+
246
+ describe('XSS Prevention - Nested Attacks', () => {
247
+ it('should handle deeply nested dangerous tags', () => {
248
+ expect(
249
+ sanitizeHTML('<p><span><strong><script>alert("xss")</script></strong></span></p>')
250
+ ).toBe('<p><span><strong></strong></span></p>');
251
+ });
252
+
253
+ it('should handle mixed safe and dangerous content', () => {
254
+ expect(
255
+ sanitizeHTML(
256
+ '<strong>Safe</strong><script>alert("xss")</script><em>Also Safe</em><iframe></iframe>'
257
+ )
258
+ ).toBe('<strong>Safe</strong><em>Also Safe</em>');
259
+ });
260
+ });
261
+
262
+ describe('XSS Prevention - Edge Cases', () => {
263
+ it('should handle malformed HTML', () => {
264
+ expect(sanitizeHTML('<strong>Unclosed tag')).toBe('<strong>Unclosed tag</strong>');
265
+ expect(sanitizeHTML('</strong>Closing tag without opening')).toBe(
266
+ 'Closing tag without opening'
267
+ );
268
+ });
269
+
270
+ it('should handle case variations', () => {
271
+ expect(sanitizeHTML('<SCRIPT>alert("xss")</SCRIPT>')).toBe('');
272
+ expect(sanitizeHTML('<Script>alert("xss")</Script>')).toBe('');
273
+ expect(sanitizeHTML('<sCrIpT>alert("xss")</sCrIpT>')).toBe('');
274
+ });
275
+
276
+ it('should handle whitespace in tags', () => {
277
+ // Tags with whitespace are treated as text (safe)
278
+ // Browser normalizes tags, so "< script >" becomes text content
279
+ const result1 = sanitizeHTML('< script >alert("xss")</ script >');
280
+ expect(result1).toContain('alert("xss")');
281
+ expect(result1).not.toContain('<script>');
282
+
283
+ // Normalized tags work fine
284
+ expect(sanitizeHTML('<strong >Bold</strong>')).toBe('<strong>Bold</strong>');
285
+ });
286
+
287
+ it('should handle null and undefined', () => {
288
+ expect(sanitizeHTML(null as any)).toBe('');
289
+ expect(sanitizeHTML(undefined as any)).toBe('');
290
+ });
291
+
292
+ it('should handle non-string input', () => {
293
+ expect(sanitizeHTML(123 as any)).toBe('');
294
+ expect(sanitizeHTML({} as any)).toBe('');
295
+ expect(sanitizeHTML([] as any)).toBe('');
296
+ });
297
+ });
298
+
299
+ describe('Real-world XSS Attack Patterns', () => {
300
+ it('should prevent common XSS payloads', () => {
301
+ // Classic script injection
302
+ expect(sanitizeHTML('<img src=x onerror=alert("xss")>')).toBe('');
303
+
304
+ // SVG with script
305
+ expect(sanitizeHTML('<svg><script>alert("xss")</script></svg>')).toBe('');
306
+
307
+ // Iframe injection
308
+ expect(sanitizeHTML('<iframe src="javascript:alert(\'xss\')"></iframe>')).toBe('');
309
+
310
+ // Event handler in allowed tag
311
+ expect(sanitizeHTML('<a href="/" onclick="alert(\'xss\')">Click</a>')).toBe(
312
+ '<a href="/">Click</a>'
313
+ );
314
+
315
+ // Mixed attack
316
+ expect(
317
+ sanitizeHTML(
318
+ '<strong>Welcome</strong><script>alert("xss")</script><a href="javascript:alert(\'xss\')">Link</a>'
319
+ )
320
+ ).toBe('<strong>Welcome</strong><a>Link</a>');
321
+ });
322
+
323
+ it('should prevent encoded XSS attacks', () => {
324
+ // HTML entity encoded
325
+ expect(sanitizeHTML('&lt;script&gt;alert("xss")&lt;/script&gt;')).toBe(
326
+ '&lt;script&gt;alert("xss")&lt;/script&gt;'
327
+ );
328
+
329
+ // URL encoded javascript: should be decoded and blocked
330
+ // Note: decodeURIComponent will decode %6A%61%76%61%73%63%72%69%70%74%3A to "javascript:"
331
+ const result = sanitizeHTML(
332
+ '<a href="%6A%61%76%61%73%63%72%69%70%74%3Aalert(\'xss\')">Link</a>'
333
+ );
334
+ expect(result).toBe('<a>Link</a>');
335
+ });
336
+ });
337
+
338
+ describe('URL Sanitization', () => {
339
+ it('should allow relative URLs', () => {
340
+ expect(sanitizeHTML('<a href="/page">Link</a>')).toBe('<a href="/page">Link</a>');
341
+ expect(sanitizeHTML('<a href="./page">Link</a>')).toBe('<a href="./page">Link</a>');
342
+ expect(sanitizeHTML('<a href="../page">Link</a>')).toBe('<a href="../page">Link</a>');
343
+ expect(sanitizeHTML('<a href="#section">Link</a>')).toBe('<a href="#section">Link</a>');
344
+ expect(sanitizeHTML('<a href="?param=value">Link</a>')).toBe(
345
+ '<a href="?param=value">Link</a>'
346
+ );
347
+ });
348
+
349
+ it('should allow http and https URLs', () => {
350
+ expect(sanitizeHTML('<a href="http://example.com">Link</a>')).toBe(
351
+ '<a href="http://example.com">Link</a>'
352
+ );
353
+ expect(sanitizeHTML('<a href="https://example.com">Link</a>')).toBe(
354
+ '<a href="https://example.com">Link</a>'
355
+ );
356
+ });
357
+
358
+ it('should allow mailto and tel URLs', () => {
359
+ expect(sanitizeHTML('<a href="mailto:test@example.com">Email</a>')).toBe(
360
+ '<a href="mailto:test@example.com">Email</a>'
361
+ );
362
+ expect(sanitizeHTML('<a href="tel:+1234567890">Call</a>')).toBe(
363
+ '<a href="tel:+1234567890">Call</a>'
364
+ );
365
+ });
366
+
367
+ it('should block javascript: URLs regardless of case', () => {
368
+ expect(sanitizeHTML('<a href="javascript:alert(1)">Link</a>')).toBe('<a>Link</a>');
369
+ expect(sanitizeHTML('<a href="JAVASCRIPT:alert(1)">Link</a>')).toBe('<a>Link</a>');
370
+ expect(sanitizeHTML('<a href="JavaScript:alert(1)">Link</a>')).toBe('<a>Link</a>');
371
+ });
372
+
373
+ it('should block data: URLs', () => {
374
+ expect(sanitizeHTML('<a href="data:text/html,<script>alert(1)</script>">Link</a>')).toBe(
375
+ '<a>Link</a>'
376
+ );
377
+ expect(sanitizeHTML('<a href="data:image/svg+xml,<svg>...</svg>">Link</a>')).toBe(
378
+ '<a>Link</a>'
379
+ );
380
+ });
381
+
382
+ it('should block other dangerous protocols', () => {
383
+ expect(sanitizeHTML('<a href="file:///etc/passwd">Link</a>')).toBe('<a>Link</a>');
384
+ expect(sanitizeHTML('<a href="vbscript:alert(1)">Link</a>')).toBe('<a>Link</a>');
385
+ });
386
+ });
387
+
388
+ describe('Complex Scenarios', () => {
389
+ it('should handle real-world banner content', () => {
390
+ const content = `
391
+ <strong>Flash Sale!</strong>
392
+ Get <em>50% off</em> everything.
393
+ <a href="/shop">Shop Now</a>
394
+ `;
395
+ const sanitized = sanitizeHTML(content);
396
+ expect(sanitized).toContain('<strong>Flash Sale!</strong>');
397
+ expect(sanitized).toContain('<em>50% off</em>');
398
+ expect(sanitized).toContain('<a href="/shop">Shop Now</a>');
399
+ expect(sanitized).not.toContain('<script>');
400
+ });
401
+
402
+ it('should preserve formatting in complex content', () => {
403
+ const content =
404
+ '<p>Welcome! <strong>New</strong> users get <em>special</em> pricing. <a href="/signup">Sign up</a> today!</p>';
405
+ const sanitized = sanitizeHTML(content);
406
+ expect(sanitized).toContain('<p>');
407
+ expect(sanitized).toContain('<strong>New</strong>');
408
+ expect(sanitized).toContain('<em>special</em>');
409
+ expect(sanitized).toContain('<a href="/signup">Sign up</a>');
410
+ });
411
+ });
412
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * HTML Sanitizer
3
+ *
4
+ * Lightweight HTML sanitizer for experience content (messages, titles).
5
+ * Whitelist-based approach that only allows safe formatting tags.
6
+ *
7
+ * Security: Prevents XSS attacks by stripping dangerous tags and attributes.
8
+ */
9
+
10
+ /**
11
+ * Allowed HTML tags for sanitization
12
+ * Only safe formatting tags are permitted
13
+ */
14
+ const ALLOWED_TAGS = ['strong', 'em', 'a', 'br', 'span', 'b', 'i', 'p'] as const;
15
+
16
+ /**
17
+ * Allowed attributes per tag
18
+ */
19
+ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
20
+ a: ['href', 'class', 'style', 'title'],
21
+ span: ['class', 'style'],
22
+ p: ['class', 'style'],
23
+ // Other tags have no attributes allowed
24
+ };
25
+
26
+ /**
27
+ * Sanitize HTML string by removing dangerous tags and attributes
28
+ *
29
+ * @param html - HTML string to sanitize
30
+ * @returns Sanitized HTML string safe for innerHTML
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * sanitizeHTML('<strong>Hello</strong><script>alert("xss")</script>');
35
+ * // Returns: '<strong>Hello</strong>'
36
+ * ```
37
+ */
38
+ export function sanitizeHTML(html: string): string {
39
+ if (!html || typeof html !== 'string') {
40
+ return '';
41
+ }
42
+
43
+ // Create a temporary DOM element to parse HTML
44
+ const temp = document.createElement('div');
45
+ temp.innerHTML = html;
46
+
47
+ /**
48
+ * Recursively sanitize a DOM node
49
+ */
50
+ function sanitizeNode(node: Node): string {
51
+ // Text nodes - escape HTML entities
52
+ if (node.nodeType === Node.TEXT_NODE) {
53
+ return escapeHTML(node.textContent || '');
54
+ }
55
+
56
+ // Element nodes
57
+ if (node.nodeType === Node.ELEMENT_NODE) {
58
+ const element = node as Element;
59
+ const tagName = element.tagName.toLowerCase();
60
+
61
+ // Handle tags with whitespace (malformed HTML like "< script >")
62
+ // Browser normalizes these, but if we see a tag that's not in our list,
63
+ // it might be a dangerous tag that was normalized
64
+ if (!tagName || tagName.includes(' ')) {
65
+ return '';
66
+ }
67
+
68
+ // If tag is not allowed, return empty string
69
+ if (!ALLOWED_TAGS.includes(tagName as any)) {
70
+ return '';
71
+ }
72
+
73
+ // Get allowed attributes for this tag
74
+ const allowedAttrs = ALLOWED_ATTRIBUTES[tagName] || [];
75
+
76
+ // Build attribute string
77
+ const attrs: string[] = [];
78
+ for (const attr of allowedAttrs) {
79
+ const value = element.getAttribute(attr);
80
+ if (value !== null) {
81
+ // Sanitize attribute values
82
+ if (attr === 'href') {
83
+ // Only allow safe URLs (http, https, mailto, tel, relative)
84
+ const sanitizedHref = sanitizeURL(value);
85
+ if (sanitizedHref) {
86
+ attrs.push(`href="${escapeAttribute(sanitizedHref)}"`);
87
+ }
88
+ } else {
89
+ // For all other attributes (title, class, style), escape HTML entities
90
+ attrs.push(`${attr}="${escapeAttribute(value)}"`);
91
+ }
92
+ }
93
+ }
94
+
95
+ const attrString = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
96
+
97
+ // Process child nodes
98
+ let innerHTML = '';
99
+ for (const child of Array.from(element.childNodes)) {
100
+ innerHTML += sanitizeNode(child);
101
+ }
102
+
103
+ // Self-closing tags
104
+ if (tagName === 'br') {
105
+ return `<br${attrString} />`;
106
+ }
107
+
108
+ return `<${tagName}${attrString}>${innerHTML}</${tagName}>`;
109
+ }
110
+
111
+ return '';
112
+ }
113
+
114
+ // Sanitize all nodes
115
+ let sanitized = '';
116
+ for (const child of Array.from(temp.childNodes)) {
117
+ sanitized += sanitizeNode(child);
118
+ }
119
+
120
+ return sanitized;
121
+ }
122
+
123
+ /**
124
+ * Escape HTML entities to prevent XSS in text content
125
+ */
126
+ function escapeHTML(text: string): string {
127
+ const div = document.createElement('div');
128
+ div.textContent = text;
129
+ return div.innerHTML;
130
+ }
131
+
132
+ /**
133
+ * Escape HTML entities for use in attribute values
134
+ */
135
+ function escapeAttribute(value: string): string {
136
+ return value
137
+ .replace(/&/g, '&amp;')
138
+ .replace(/</g, '&lt;')
139
+ .replace(/>/g, '&gt;')
140
+ .replace(/"/g, '&quot;')
141
+ .replace(/'/g, '&#39;');
142
+ }
143
+
144
+ /**
145
+ * Sanitize URL to prevent javascript: and data: XSS attacks
146
+ *
147
+ * @param url - URL to sanitize
148
+ * @returns Sanitized URL or empty string if unsafe
149
+ */
150
+ function sanitizeURL(url: string): string {
151
+ if (!url || typeof url !== 'string') {
152
+ return '';
153
+ }
154
+
155
+ // Decode URL-encoded characters to check for encoded attacks
156
+ let decoded: string;
157
+ try {
158
+ decoded = decodeURIComponent(url);
159
+ } catch {
160
+ // If decoding fails, use original
161
+ decoded = url;
162
+ }
163
+
164
+ const trimmed = decoded.trim().toLowerCase();
165
+
166
+ // Block javascript: and data: protocols (check both original and decoded)
167
+ if (
168
+ trimmed.startsWith('javascript:') ||
169
+ trimmed.startsWith('data:') ||
170
+ url.toLowerCase().trim().startsWith('javascript:') ||
171
+ url.toLowerCase().trim().startsWith('data:')
172
+ ) {
173
+ return '';
174
+ }
175
+
176
+ // Allow http, https, mailto, tel, and relative URLs
177
+ if (
178
+ trimmed.startsWith('http://') ||
179
+ trimmed.startsWith('https://') ||
180
+ trimmed.startsWith('mailto:') ||
181
+ trimmed.startsWith('tel:') ||
182
+ trimmed.startsWith('/') ||
183
+ trimmed.startsWith('#') ||
184
+ trimmed.startsWith('?')
185
+ ) {
186
+ return url; // Return original (case preserved)
187
+ }
188
+
189
+ // Allow relative paths without protocol
190
+ if (!trimmed.includes(':')) {
191
+ return url;
192
+ }
193
+
194
+ // Block everything else
195
+ return '';
196
+ }