Skip to content

Commit 62340dd

Browse files
committed
Tighten up dom sanitizing rules
This change adds restrictions to our dom sanitizer rules. The goal is to make the core set of rules more restrictive and require callers to opt into more permissive tags Main changes: - Remove input tags being allowed by default - Remove some markdown specific / custom attributes from the default list - Remove id, class, and style attrs from default layer - Split up link/media allowed commands - Restructure api to make it more clear how you can override or augment tags/attrs
1 parent 9cb3511 commit 62340dd

File tree

6 files changed

+169
-82
lines changed

6 files changed

+169
-82
lines changed

src/vs/base/browser/domSanitize.ts

Lines changed: 68 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,6 @@ import { DisposableStore, IDisposable, toDisposable } from '../common/lifecycle.
77
import { Schemas } from '../common/network.js';
88
import dompurify from './dompurify/dompurify.js';
99

10-
const defaultSafeProtocols = [
11-
Schemas.http,
12-
Schemas.https,
13-
Schemas.command,
14-
];
1510

1611
/**
1712
* List of safe, non-input html tags.
@@ -83,40 +78,27 @@ export const basicMarkupHtmlTags = Object.freeze([
8378
'var',
8479
'video',
8580
'wbr',
86-
87-
// TODO: Move these out of the default
88-
'select',
89-
'input',
9081
]);
9182

9283
export const defaultAllowedAttrs = Object.freeze([
9384
'href',
9485
'target',
95-
'title',
96-
'name',
9786
'src',
9887
'alt',
88+
'title',
89+
'for',
90+
'name',
9991
'role',
10092
'tabindex',
101-
'width',
102-
'height',
103-
'align',
10493
'x-dispatch',
10594
'required',
10695
'checked',
10796
'placeholder',
10897
'type',
10998
'start',
110-
111-
// TODO: See if we can move these out of the default
112-
'for',
113-
'role',
114-
'data-href',
115-
'data-command',
116-
'data-code',
117-
'id',
118-
'class',
119-
'style',
99+
'width',
100+
'height',
101+
'align',
120102
]);
121103

122104

@@ -134,28 +116,35 @@ function addDompurifyHook(hook: 'uponSanitizeElement' | 'uponSanitizeAttribute',
134116
* Hooks dompurify using `afterSanitizeAttributes` to check that all `href` and `src`
135117
* attributes are valid.
136118
*/
137-
function hookDomPurifyHrefAndSrcSanitizer(allowedProtocols: readonly string[] | '*', allowDataImages = false): IDisposable {
119+
function hookDomPurifyHrefAndSrcSanitizer(allowedLinkProtocols: readonly string[] | '*', allowedMediaProtocols: readonly string[]): IDisposable {
138120
// https://github.com/cure53/DOMPurify/blob/main/demos/hooks-scheme-allowlist.html
139121
// build an anchor to map URLs to
140122
const anchor = document.createElement('a');
141123

124+
function validateLink(value: string, allowedProtocols: readonly string[] | '*'): boolean {
125+
if (allowedProtocols === '*') {
126+
return true; // allow all protocols
127+
}
128+
129+
anchor.href = value;
130+
return allowedProtocols.includes(anchor.protocol.replace(/:$/, ''));
131+
}
132+
142133
dompurify.addHook('afterSanitizeAttributes', (node) => {
143134
// check all href/src attributes for validity
144135
for (const attr of ['href', 'src']) {
145136
if (node.hasAttribute(attr)) {
146137
const attrValue = node.getAttribute(attr) as string;
147-
if (attr === 'href' && attrValue.startsWith('#')) {
148-
// Allow fragment links
149-
continue;
150-
}
138+
if (attr === 'href') {
151139

152-
anchor.href = attrValue;
153-
if (allowedProtocols !== '*' && !allowedProtocols.includes(anchor.protocol.replace(/:$/, ''))) {
154-
if (allowDataImages && attr === 'src' && anchor.href.startsWith('data:')) {
155-
continue;
140+
if (!attrValue.startsWith('#') && !validateLink(attrValue, allowedLinkProtocols)) {
141+
node.removeAttribute(attr);
156142
}
157143

158-
node.removeAttribute(attr);
144+
} else {// 'src'
145+
if (!validateLink(attrValue, allowedMediaProtocols)) {
146+
node.removeAttribute(attr);
147+
}
159148
}
160149
}
161150
}
@@ -165,16 +154,35 @@ function hookDomPurifyHrefAndSrcSanitizer(allowedProtocols: readonly string[] |
165154
}
166155

167156
export interface SanitizeOptions {
168-
readonly overrideAllowedTags?: readonly string[];
169-
readonly overrideAllowedAttributes?: readonly string[];
157+
/**
158+
* Configured the allowed html tags.
159+
*/
160+
readonly allowedTags?: {
161+
readonly override?: readonly string[];
162+
readonly augment?: readonly string[];
163+
};
164+
165+
/**
166+
* Configured the allowed html attributes.
167+
*/
168+
readonly allowedAttributes?: {
169+
readonly override?: readonly string[];
170+
readonly augment?: readonly string[];
171+
};
170172

171173
/**
172-
* List of allowed protocols for `href` and `src` attributes.
173-
*
174-
* If this is
174+
* List of allowed protocols for `href` attributes.
175175
*/
176-
readonly overrideAllowedProtocols?: readonly string[] | '*';
177-
readonly allowDataImages?: boolean;
176+
readonly allowedLinkProtocols?: {
177+
readonly override?: readonly string[] | '*';
178+
};
179+
180+
/**
181+
* List of allowed protocols for `src` attributes.
182+
*/
183+
readonly allowedMediaProtocols?: {
184+
readonly override?: readonly string[];
185+
};
178186

179187
readonly hooks?: {
180188
readonly uponSanitizeElement?: UponSanitizeElementCb;
@@ -205,16 +213,29 @@ export function sanitizeHtml(untrusted: string, config?: SanitizeOptions): Trust
205213
try {
206214
const resolvedConfig: dompurify.Config = { ...defaultDomPurifyConfig };
207215

208-
if (config?.overrideAllowedTags) {
209-
resolvedConfig.ALLOWED_TAGS = [...config.overrideAllowedTags];
216+
if (config?.allowedTags) {
217+
if (config.allowedTags.override) {
218+
resolvedConfig.ALLOWED_TAGS = [...config.allowedTags.override];
219+
}
220+
221+
if (config?.allowedTags?.augment) {
222+
resolvedConfig.ALLOWED_TAGS = [...(resolvedConfig.ALLOWED_TAGS ?? []), ...config.allowedTags.augment];
223+
}
210224
}
211225

212-
if (config?.overrideAllowedAttributes) {
213-
resolvedConfig.ALLOWED_ATTR = [...config.overrideAllowedAttributes];
226+
if (config?.allowedAttributes) {
227+
if (config.allowedAttributes.override) {
228+
resolvedConfig.ALLOWED_ATTR = [...config.allowedAttributes.override];
229+
}
230+
231+
if (config?.allowedAttributes?.augment) {
232+
resolvedConfig.ALLOWED_ATTR = [...(resolvedConfig.ALLOWED_ATTR ?? []), ...config.allowedAttributes.augment];
233+
}
214234
}
215235

216-
const allowedProtocols = config?.overrideAllowedProtocols ?? defaultSafeProtocols;
217-
store.add(hookDomPurifyHrefAndSrcSanitizer(allowedProtocols, config?.allowDataImages));
236+
store.add(hookDomPurifyHrefAndSrcSanitizer(
237+
config?.allowedLinkProtocols?.override ?? [Schemas.http, Schemas.https],
238+
config?.allowedMediaProtocols?.override ?? [Schemas.http, Schemas.https]));
218239

219240
if (config?.hooks?.uponSanitizeElement) {
220241
store.add(addDompurifyHook('uponSanitizeElement', config?.hooks.uponSanitizeElement));

src/vs/base/browser/markdownRenderer.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,6 @@ export const allowedMarkdownHtmlAttributes = [
419419
'class',
420420
'colspan',
421421
'controls',
422-
'data-code',
423-
'data-href',
424422
'disabled',
425423
'draggable',
426424
'height',
@@ -437,17 +435,20 @@ export const allowedMarkdownHtmlAttributes = [
437435
'width',
438436
'start',
439437

438+
// Custom markdown attributes
439+
'data-code',
440+
'data-href',
441+
440442
// These attributes are sanitized in the hooks
441443
'style',
442444
'class',
443445
];
444446

445447
function getSanitizerOptions(options: IInternalSanitizerOptions): domSanitize.SanitizeOptions {
446-
const allowedSchemes = [
448+
const allowedLinkSchemes = [
447449
Schemas.http,
448450
Schemas.https,
449451
Schemas.mailto,
450-
Schemas.data,
451452
Schemas.file,
452453
Schemas.vscodeFileResource,
453454
Schemas.vscodeRemote,
@@ -456,21 +457,38 @@ function getSanitizerOptions(options: IInternalSanitizerOptions): domSanitize.Sa
456457
];
457458

458459
if (options.isTrusted) {
459-
allowedSchemes.push(Schemas.command);
460+
allowedLinkSchemes.push(Schemas.command);
460461
}
461462

462463
if (options.allowedProductProtocols) {
463-
allowedSchemes.push(...options.allowedProductProtocols);
464+
allowedLinkSchemes.push(...options.allowedProductProtocols);
464465
}
465466

466467
return {
467468
// allowedTags should included everything that markdown renders to.
468469
// Since we have our own sanitize function for marked, it's possible we missed some tag so let dompurify make sure.
469470
// HTML tags that can result from markdown are from reading https://spec.commonmark.org/0.29/
470471
// HTML table tags that can result from markdown are from https://github.github.com/gfm/#tables-extension-
471-
overrideAllowedTags: options.allowedTags ?? domSanitize.basicMarkupHtmlTags,
472-
overrideAllowedAttributes: allowedMarkdownHtmlAttributes,
473-
overrideAllowedProtocols: allowedSchemes,
472+
allowedTags: {
473+
override: options.allowedTags ?? domSanitize.basicMarkupHtmlTags
474+
},
475+
allowedAttributes: {
476+
override: allowedMarkdownHtmlAttributes,
477+
},
478+
allowedLinkProtocols: {
479+
override: allowedLinkSchemes,
480+
},
481+
allowedMediaProtocols: {
482+
override: [
483+
Schemas.http,
484+
Schemas.https,
485+
Schemas.data,
486+
Schemas.file,
487+
Schemas.vscodeFileResource,
488+
Schemas.vscodeRemote,
489+
Schemas.vscodeRemoteResource,
490+
]
491+
},
474492
hooks: {
475493
uponSanitizeAttribute: (element, e) => {
476494
if (options.customAttrSanitizer) {

src/vs/base/test/browser/domSanitize.test.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js';
77
import { sanitizeHtml } from '../../browser/domSanitize.js';
88
import * as assert from 'assert';
9+
import { Schemas } from '../../common/network.js';
910

1011
suite('DomSanitize', () => {
1112

@@ -34,19 +35,26 @@ suite('DomSanitize', () => {
3435
});
3536

3637
test('allows custom tags via config', () => {
37-
const html = '<custom-tag>hello</custom-tag>';
38-
const result = sanitizeHtml(html, {
39-
overrideAllowedTags: ['custom-tag']
40-
});
41-
const str = result.toString();
42-
43-
assert.ok(str.includes('<custom-tag>hello</custom-tag>'));
38+
{
39+
const html = '<div>removed</div><custom-tag>hello</custom-tag>';
40+
const result = sanitizeHtml(html, {
41+
allowedTags: { override: ['custom-tag'] }
42+
});
43+
assert.strictEqual(result.toString(), 'removed<custom-tag>hello</custom-tag>');
44+
}
45+
{
46+
const html = '<div>kept</div><augmented-tag>world</augmented-tag>';
47+
const result = sanitizeHtml(html, {
48+
allowedTags: { augment: ['augmented-tag'] }
49+
});
50+
assert.strictEqual(result.toString(), '<div>kept</div><augmented-tag>world</augmented-tag>');
51+
}
4452
});
4553

4654
test('allows custom attributes via config', () => {
4755
const html = '<div custom-attr="value">content</div>';
4856
const result = sanitizeHtml(html, {
49-
overrideAllowedAttributes: ['custom-attr']
57+
allowedAttributes: { override: ['custom-attr'] }
5058
});
5159
const str = result.toString();
5260

@@ -98,7 +106,9 @@ suite('DomSanitize', () => {
98106

99107
test('allows data images when enabled', () => {
100108
const html = '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==">';
101-
const result = sanitizeHtml(html, { allowDataImages: true });
109+
const result = sanitizeHtml(html, {
110+
allowedMediaProtocols: { override: [Schemas.data] }
111+
});
102112
const str = result.toString();
103113

104114
assert.ok(str.includes('src="data:image/png;base64,'));

src/vs/workbench/contrib/issue/browser/issueFormService.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,23 @@ export class IssueFormService implements IIssueFormService {
9393
// removes preset monaco-workbench
9494
auxiliaryWindow.container.remove();
9595
auxiliaryWindow.window.document.body.appendChild(div);
96-
safeInnerHtml(div, BaseHtml());
96+
safeInnerHtml(div, BaseHtml(), {
97+
// Also allow input elements
98+
allowedTags: {
99+
augment: [
100+
'input',
101+
'select',
102+
'checkbox',
103+
]
104+
},
105+
allowedAttributes: {
106+
augment: [
107+
'id',
108+
'class',
109+
'style',
110+
]
111+
}
112+
});
97113

98114
this.issueReporterWindow = auxiliaryWindow.window;
99115
} else {

src/vs/workbench/contrib/markdown/browser/markdownDocumentRenderer.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -163,20 +163,28 @@ const allowedProtocols = [
163163
];
164164

165165
function sanitize(documentContent: string, allowAllProtocols = false): TrustedHTML {
166+
// TODO: Move most of these options to the callers
166167
return sanitizeHtml(documentContent, {
167-
overrideAllowedProtocols: allowAllProtocols ? '*' : allowedProtocols,
168-
overrideAllowedTags: [
169-
...basicMarkupHtmlTags,
170-
'input',
171-
'checkbox',
172-
'checklist',
173-
],
174-
overrideAllowedAttributes: [
175-
...allowedMarkdownHtmlAttributes,
176-
'data-command', 'name', 'id', 'role', 'tabindex',
177-
'x-dispatch',
178-
'required', 'checked', 'placeholder', 'when-checked', 'checked-on',
179-
],
168+
allowedLinkProtocols: {
169+
override: allowAllProtocols ? '*' : allowedProtocols,
170+
},
171+
allowedTags: {
172+
override: [
173+
...basicMarkupHtmlTags,
174+
'input',
175+
'select',
176+
'checkbox',
177+
'checklist',
178+
],
179+
},
180+
allowedAttributes: {
181+
override: [
182+
...allowedMarkdownHtmlAttributes,
183+
'data-command', 'name', 'id', 'role', 'tabindex',
184+
'x-dispatch',
185+
'required', 'checked', 'placeholder', 'when-checked', 'checked-on',
186+
],
187+
}
180188
});
181189
}
182190

0 commit comments

Comments
 (0)