Skip to content

Commit ee69a6d

Browse files
authored
Merge pull request microsoft#256143 from mjbvz/marvellous-swordfish
Tighten up dom sanitizing rules
2 parents 9bb90bd + 62340dd commit ee69a6d

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)