Skip to content

Commit fa8406f

Browse files
authored
feat: use new Sanitizer API & ditch stripScripts() (#496)
1 parent 3866ea7 commit fa8406f

10 files changed

Lines changed: 53 additions & 40 deletions

File tree

lerna.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
"commentPullRequests": "🎉 This pull request is included in version %v 📦<br>🔗 The release notes are available at: [GitHub Release](%u) 🚀",
2121
"message": "chore(release): publish new version %s",
2222
"releaseFooterMessage": "<br>🎉 Another great release available on GitHub and NPM 🤖. Star us on GitHub ⭐"
23+
},
24+
"watch": {
25+
"noBail": true,
26+
"noShell": true
2327
}
2428
},
2529
"changelogPreset": "conventionalcommits",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"build:lib": "pnpm -r --stream --filter=\"{packages/multiple-select-vanilla/**}\" build",
3333
"dev": "pnpm -r dev:init && run-p dev:watch build:watch --npm-path pnpm",
3434
"dev:watch": "pnpm -r --parallel --stream dev",
35-
"build:watch": "lerna watch --no-bail --file-delimiter=\",\" --glob=\"src/**/*.{ts,scss}\" -- cross-env-shell pnpm -r --filter $LERNA_PACKAGE_NAME build:watch --files=$LERNA_FILE_CHANGES",
35+
"build:watch": "lerna watch --file-delimiter=\",\" --glob=\"src/**/*.{ts,scss}\" -- cross-env-shell 'pnpm -r --filter $LERNA_PACKAGE_NAME build:watch --files=$LERNA_FILE_CHANGES'",
3636
"dev:demo": "pnpm -r --stream --filter=\"{packages/demo/**}\" dev",
3737
"dev:lib": "pnpm -r --stream --filter=\"{packages/multiple-select-vanilla/**}\" dev",
3838
"biome:lint:check": "biome lint ./packages",

packages/demo/src/options/options28.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import DOMPurify from 'dompurify';
1+
// import DOMPurify from 'dompurify';
22
import { type MultipleSelectInstance, multipleSelect } from 'multiple-select-vanilla';
33

44
export default class Example {
@@ -10,7 +10,8 @@ export default class Example {
1010
labelTemplate: el => {
1111
return `<i class="fa fa-star"></i>${el.getAttribute('label')}`;
1212
},
13-
sanitizer: html => DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true }),
13+
// default sanitizer uses the Sanitizer API: https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API
14+
// sanitizer: html => DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true }),
1415
}) as MultipleSelectInstance;
1516
}
1617

packages/demo/src/options/options32.html

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,7 @@ <h2 class="bd-title">
2727
<label class="col-sm-3 text-end">Select placeholder with XSS</label>
2828

2929
<div class="col-sm-9">
30-
<select id="select1" multiple="multiple" class="full-width">
31-
<option value="1">January</option>
32-
<option value="2">February</option>
33-
<option value="3">March</option>
34-
<option value="4">April</option>
35-
<option value="5">May</option>
36-
<option value="6">June</option>
37-
<option value="7">July</option>
38-
<option value="8">August</option>
39-
<option value="9">September</option>
40-
<option value="10">October</option>
41-
<option value="11">November</option>
42-
<option value="12">December</option>
43-
</select>
30+
<select id="select1" multiple="multiple" class="full-width"></select>
4431
</div>
4532
</div>
4633
</div>

packages/demo/src/options/options32.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
1+
import DOMPurify from 'dompurify';
12
import { type MultipleSelectInstance, multipleSelect } from 'multiple-select-vanilla';
23

34
export default class Example {
45
ms1?: MultipleSelectInstance;
56

67
mount() {
78
this.ms1 = multipleSelect('#select1', {
8-
placeholder: 'Placeholder with cross-site scripting code...<img src="not-found" onerror=alert("Hacked")>',
9-
sanitizer: (dirtyHtml: string) =>
10-
typeof dirtyHtml === 'string'
11-
? decodeURIComponent(dirtyHtml).replace(
12-
/(\b)(on[a-z]+)(\s*)=|javascript:([^>]*)[^>]*|(<\s*)(\/*)script([<>]*).*(<\s*)(\/*)script(>*)|(&lt;)(\/*)(script|script defer)(.*)(&gt;|&gt;">)/gi,
13-
'',
14-
)
15-
: dirtyHtml,
9+
data: [
10+
{
11+
value: '<strong style="color: green">Safe HTML value</strong>',
12+
text: '1. Safe HTML example',
13+
},
14+
{
15+
value: '<img src="x" onerror="alert(`This should be removed by stripScripts`)">Blocked by stripScripts',
16+
text: '2. Payload blocked by stripScripts',
17+
},
18+
{
19+
value: '<iframe srcdoc="<script>alert(\'XSS\')\n<\/script>"></iframe>',
20+
text: '3. Payload that bypasses stripScripts and executes',
21+
},
22+
],
23+
filter: true,
24+
placeholder: "Placeholder with cross-site scripting code...&lt;script\&gt;alert('XSS')&lt;\/script&gt;",
25+
useSelectOptionLabelToHtml: true,
1626

17-
// or even better, use dedicated libraries like DOM Purify: https://github.com/cure53/DOMPurify
18-
// sanitizer: (html) => DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true }),
27+
// you can use DOMPurify to sanitize data
28+
// the default sanitizer is the Sanitizer API: https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API
29+
sanitizer: html => DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true }),
1930
}) as MultipleSelectInstance;
2031
}
2132

packages/multiple-select-vanilla/src/MultipleSelectInstance.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
insertAfter,
2323
toggleElement,
2424
} from './utils/domUtils.js';
25-
import { compareObjects, deepCopy, findByParam, removeDiacritics, removeUndefined, setDataKeys, stripScripts } from './utils/utils.js';
25+
import { compareObjects, deepCopy, findByParam, removeDiacritics, removeUndefined, setDataKeys } from './utils/utils.js';
2626

2727
const OPTIONS_LIST_SELECTOR = '.ms-select-all, ul li[data-key]';
2828
const OPTIONS_HIGHLIGHT_LIST_SELECTOR = '.ms-select-all.highlighted, ul li[data-key].highlighted';
@@ -1532,7 +1532,7 @@ export class MultipleSelectInstance {
15321532
const getSelectOptionHtml = () => {
15331533
if (this.options.useSelectOptionLabel || this.options.useSelectOptionLabelToHtml) {
15341534
const labels = valueSelects.join(this.options.displayDelimiter);
1535-
return this.options.useSelectOptionLabelToHtml ? stripScripts(labels) : labels;
1535+
return labels;
15361536
}
15371537
return textSelects.join(this.options.displayDelimiter);
15381538
};

packages/multiple-select-vanilla/src/constants.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,23 @@ const DEFAULTS: Partial<MultipleSelectOption> = {
8585
onDestroy: noopFalse,
8686
onAfterDestroy: noopFalse,
8787
onDestroyed: noopFalse,
88+
sanitizer: text => {
89+
if ('setHTML' in Element.prototype) {
90+
const container = document.createElement('div');
91+
// @ts-ignore: experimental API
92+
container.setHTML(text, {
93+
sanitizer: new Sanitizer({
94+
// let's add the most common elements & attributes
95+
// also see: https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API/Default_sanitizer_configuration
96+
elements: ['i', 'span', 'div', 'p', 'b', 'strong', 'em', 'br', 'ul', 'ol', 'li', 'a', 'img'],
97+
attributes: ['class', 'title', 'alt', 'src', 'href', 'target', 'rel', 'width', 'height', 'level'],
98+
replaceWithChildrenElements: [],
99+
}),
100+
});
101+
return container.innerHTML;
102+
}
103+
return text;
104+
},
88105
};
89106

90107
const METHODS = [

packages/multiple-select-vanilla/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,4 @@ export {
3030
removeDiacritics,
3131
removeUndefined,
3232
setDataKeys,
33-
stripScripts,
3433
} from './utils/utils.js';

packages/multiple-select-vanilla/src/utils/utils.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,6 @@ export function findByParam(data: any, param: any, value: any) {
104104
}
105105
}
106106

107-
export function stripScripts(dirtyHtml: string) {
108-
return dirtyHtml.replace(
109-
/(\b)(on[a-z]+)(\s*)=([^>]*)|javascript:([^>]*)[^>]*|(<\s*)(\/*)script([<>]*).*(<\s*)(\/*)script(>*)|(&lt;|&#60;)(\/*)(script|script defer)(.*)(&#62;|&gt;|&gt;">)/gi,
110-
'',
111-
);
112-
}
113-
114107
export function removeUndefined<T extends Record<string, unknown> = Record<string, unknown>>(obj: T): T {
115108
Object.keys(obj).forEach(key => (!isDefined(obj[key]) ? delete obj[key] : ''));
116109
return obj;

playwright/e2e/options32.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { expect, test } from '@playwright/test';
22

33
test.describe('Options 32 - Sanitizer', () => {
4-
test('select shows image not found and JS alert should be sanitized and not trigger', async ({ page }) => {
4+
test('select last 2 options should not trigger any alert(XSS)', async ({ page }) => {
55
let alertTriggered = false;
66
page.on('dialog', async alert => {
77
alertTriggered = true;
@@ -11,8 +11,9 @@ test.describe('Options 32 - Sanitizer', () => {
1111

1212
await page.goto('#/options32');
1313
await page.locator('.ms-parent', { hasText: 'Placeholder with cross-site scripting code...' }).click();
14-
await page.locator('span').filter({ hasText: 'February' }).click();
15-
await page.locator('span').filter({ hasText: 'March' }).click();
14+
await page.locator('span').filter({ hasText: '1. Safe HTML example' }).click();
15+
await page.locator('span').filter({ hasText: '2. Payload blocked by stripScripts' }).click();
16+
await page.locator('span').filter({ hasText: '3. Payload that bypasses stripScripts and executes' }).click();
1617
await expect(alertTriggered).toBeFalsy();
1718
});
1819
});

0 commit comments

Comments
 (0)