@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.
- package/.turbo/turbo-build.log +17 -0
- package/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/dist/index.d.ts +213 -0
- package/dist/index.js +634 -0
- package/dist/index.js.map +1 -0
- package/package.json +18 -11
- package/src/banner/banner.test.ts +370 -10
- package/src/banner/banner.ts +246 -119
- package/src/types.ts +22 -0
- package/src/utils/sanitize.test.ts +412 -0
- package/src/utils/sanitize.ts +196 -0
|
@@ -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: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><script></strong>')).toBe(
|
|
232
|
+
'<strong><script></strong>'
|
|
233
|
+
);
|
|
234
|
+
expect(sanitizeHTML('<script>alert("xss")</script>')).toBe(
|
|
235
|
+
'<script>alert("xss")</script>'
|
|
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"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('<script>alert("xss")</script>')).toBe(
|
|
326
|
+
'<script>alert("xss")</script>'
|
|
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, '&')
|
|
138
|
+
.replace(/</g, '<')
|
|
139
|
+
.replace(/>/g, '>')
|
|
140
|
+
.replace(/"/g, '"')
|
|
141
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|