Skip to content

Commit 1f90863

Browse files
committed
Merge branch 'main' into outer-takin
2 parents 1cd8ad3 + b3a5353 commit 1f90863

File tree

23 files changed

+331
-127
lines changed

23 files changed

+331
-127
lines changed

src/vs/base/browser/domSanitize.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,18 @@ function hookDomPurifyHrefAndSrcSanitizer(allowedLinkProtocols: readonly string[
153153
return toDisposable(() => dompurify.removeHook('afterSanitizeAttributes'));
154154
}
155155

156+
/**
157+
* Predicate that checks if an attribute should be kept or removed.
158+
*
159+
* @returns A boolean indicating whether the attribute should be kept or a string with the sanitized value (which implicitly keeps the attribute)
160+
*/
161+
export type SanitizeAttributePredicate = (node: Element, data: { readonly attrName: string; readonly attrValue: string }) => boolean | string;
162+
163+
export interface SanitizeAttributeRule {
164+
readonly attributeName: string;
165+
shouldKeep: SanitizeAttributePredicate;
166+
}
167+
156168
export interface DomSanitizerConfig {
157169
/**
158170
* Configured the allowed html tags.
@@ -166,8 +178,8 @@ export interface DomSanitizerConfig {
166178
* Configured the allowed html attributes.
167179
*/
168180
readonly allowedAttributes?: {
169-
readonly override?: readonly string[];
170-
readonly augment?: readonly string[];
181+
readonly override?: ReadonlyArray<string | SanitizeAttributeRule>;
182+
readonly augment?: ReadonlyArray<string | SanitizeAttributeRule>;
171183
};
172184

173185
/**
@@ -190,11 +202,6 @@ export interface DomSanitizerConfig {
190202
* For example, <p><bad>"text"</bad></p> becomes <p>"<bad>text</bad>"</p>.
191203
*/
192204
readonly replaceWithPlaintext?: boolean;
193-
194-
// TODO: move these into more controlled api
195-
readonly _do_not_use_hooks?: {
196-
readonly uponSanitizeAttribute?: UponSanitizeAttributeCb;
197-
};
198205
}
199206

200207
const defaultDomPurifyConfig = Object.freeze({
@@ -230,16 +237,27 @@ export function sanitizeHtml(untrusted: string, config?: DomSanitizerConfig): Tr
230237
}
231238
}
232239

240+
let resolvedAttributes: Array<string | SanitizeAttributeRule> = [...defaultAllowedAttrs];
233241
if (config?.allowedAttributes) {
234242
if (config.allowedAttributes.override) {
235-
resolvedConfig.ALLOWED_ATTR = [...config.allowedAttributes.override];
243+
resolvedAttributes = [...config.allowedAttributes.override];
236244
}
237245

238246
if (config.allowedAttributes.augment) {
239-
resolvedConfig.ALLOWED_ATTR = [...(resolvedConfig.ALLOWED_ATTR ?? []), ...config.allowedAttributes.augment];
247+
resolvedAttributes = [...resolvedAttributes, ...config.allowedAttributes.augment];
248+
}
249+
}
250+
251+
const allowedAttrNames = new Set(resolvedAttributes.map(attr => typeof attr === 'string' ? attr : attr.attributeName));
252+
const allowedAttrPredicates = new Map<string, SanitizeAttributeRule>();
253+
for (const attr of resolvedAttributes) {
254+
if (typeof attr !== 'string') {
255+
allowedAttrPredicates.set(attr.attributeName, attr);
240256
}
241257
}
242258

259+
resolvedConfig.ALLOWED_ATTR = Array.from(allowedAttrNames);
260+
243261
store.add(hookDomPurifyHrefAndSrcSanitizer(
244262
config?.allowedLinkProtocols?.override ?? [Schemas.http, Schemas.https],
245263
config?.allowedMediaProtocols?.override ?? [Schemas.http, Schemas.https]));
@@ -248,8 +266,21 @@ export function sanitizeHtml(untrusted: string, config?: DomSanitizerConfig): Tr
248266
store.add(addDompurifyHook('uponSanitizeElement', replaceWithPlainTextHook));
249267
}
250268

251-
if (config?._do_not_use_hooks?.uponSanitizeAttribute) {
252-
store.add(addDompurifyHook('uponSanitizeAttribute', config._do_not_use_hooks.uponSanitizeAttribute));
269+
if (allowedAttrPredicates.size) {
270+
store.add(addDompurifyHook('uponSanitizeAttribute', (node, e) => {
271+
const predicate = allowedAttrPredicates.get(e.attrName);
272+
if (predicate) {
273+
const result = predicate.shouldKeep(node, e);
274+
if (typeof result === 'string') {
275+
e.keepAttr = true;
276+
e.attrValue = result;
277+
} else {
278+
e.keepAttr = result;
279+
}
280+
} else {
281+
e.keepAttr = allowedAttrNames.has(e.attrName);
282+
}
283+
}));
253284
}
254285

255286
return dompurify.sanitize(untrusted, {

src/vs/base/browser/markdownRenderer.ts

Lines changed: 32 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,8 @@ export interface MarkdownSanitizerConfig {
5757
readonly override: readonly string[];
5858
};
5959
readonly allowedAttributes?: {
60-
readonly override: readonly string[];
60+
readonly override: ReadonlyArray<string | domSanitize.SanitizeAttributeRule>;
6161
};
62-
readonly customAttrSanitizer?: (attrName: string, attrValue: string) => boolean | string;
6362
readonly allowedLinkSchemes?: {
6463
readonly augment: readonly string[];
6564
};
@@ -451,14 +450,12 @@ export const allowedMarkdownHtmlTags = Object.freeze([
451450
'input', // Allow inputs for rendering checkboxes. Other types of inputs are removed and the inputs are always disabled
452451
]);
453452

454-
export const allowedMarkdownHtmlAttributes = [
453+
export const allowedMarkdownHtmlAttributes = Object.freeze<Array<string | domSanitize.SanitizeAttributeRule>>([
455454
'align',
456455
'autoplay',
457456
'alt',
458-
'checked',
459457
'colspan',
460458
'controls',
461-
'disabled',
462459
'draggable',
463460
'height',
464461
'href',
@@ -473,16 +470,42 @@ export const allowedMarkdownHtmlAttributes = [
473470
'type',
474471
'width',
475472
'start',
473+
474+
// Input (For disabled inputs)
475+
'checked',
476+
'disabled',
476477
'value',
477478

478479
// Custom markdown attributes
479480
'data-code',
480481
'data-href',
481482

482-
// These attributes are sanitized in the hooks
483-
'style',
484-
'class',
485-
];
483+
// Only allow very specific styles
484+
{
485+
attributeName: 'style',
486+
shouldKeep: (element, data) => {
487+
if (element.tagName === 'SPAN') {
488+
if (data.attrName === 'style') {
489+
return /^(color\:(#[0-9a-fA-F]+|var\(--vscode(-[a-zA-Z0-9]+)+\));)?(background-color\:(#[0-9a-fA-F]+|var\(--vscode(-[a-zA-Z0-9]+)+\));)?(border-radius:[0-9]+px;)?$/.test(data.attrValue);
490+
}
491+
}
492+
return false;
493+
}
494+
},
495+
496+
// Only allow codicons for classes
497+
{
498+
attributeName: 'class',
499+
shouldKeep: (element, data) => {
500+
if (element.tagName === 'SPAN') {
501+
if (data.attrName === 'class') {
502+
return /^codicon codicon-[a-z\-]+( codicon-modifier-[a-z\-]+)?$/.test(data.attrValue);
503+
}
504+
}
505+
return false;
506+
},
507+
},
508+
]);
486509

487510
function getDomSanitizerConfig(isTrusted: boolean | MarkdownStringTrustedOptions, options: MarkdownSanitizerConfig): domSanitize.DomSanitizerConfig {
488511
const allowedLinkSchemes = [
@@ -530,45 +553,6 @@ function getDomSanitizerConfig(isTrusted: boolean | MarkdownStringTrustedOptions
530553
]
531554
},
532555
replaceWithPlaintext: options.replaceWithPlaintext,
533-
_do_not_use_hooks: {
534-
uponSanitizeAttribute: (element, e) => {
535-
if (options.customAttrSanitizer) {
536-
const result = options.customAttrSanitizer(e.attrName, e.attrValue);
537-
if (typeof result === 'string') {
538-
if (result) {
539-
e.attrValue = result;
540-
e.keepAttr = true;
541-
} else {
542-
e.keepAttr = false;
543-
}
544-
} else {
545-
e.keepAttr = result;
546-
}
547-
return;
548-
}
549-
550-
if (e.attrName === 'style' || e.attrName === 'class') {
551-
if (element.tagName === 'SPAN') {
552-
if (e.attrName === 'style') {
553-
e.keepAttr = /^(color\:(#[0-9a-fA-F]+|var\(--vscode(-[a-zA-Z0-9]+)+\));)?(background-color\:(#[0-9a-fA-F]+|var\(--vscode(-[a-zA-Z0-9]+)+\));)?(border-radius:[0-9]+px;)?$/.test(e.attrValue);
554-
return;
555-
} else if (e.attrName === 'class') {
556-
e.keepAttr = /^codicon codicon-[a-z\-]+( codicon-modifier-[a-z\-]+)?$/.test(e.attrValue);
557-
return;
558-
}
559-
}
560-
561-
e.keepAttr = false;
562-
return;
563-
} else if (element.tagName === 'INPUT' && element.attributes.getNamedItem('type')?.value === 'checkbox') {
564-
if ((e.attrName === 'type' && e.attrValue === 'checkbox') || e.attrName === 'disabled' || e.attrName === 'checked') {
565-
e.keepAttr = true;
566-
return;
567-
}
568-
e.keepAttr = false;
569-
}
570-
},
571-
}
572556
};
573557
}
574558

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

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js';
7-
import { sanitizeHtml } from '../../browser/domSanitize.js';
86
import * as assert from 'assert';
7+
import { sanitizeHtml } from '../../browser/domSanitize.js';
98
import { Schemas } from '../../common/network.js';
9+
import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js';
1010

1111
suite('DomSanitize', () => {
1212

@@ -114,6 +114,45 @@ suite('DomSanitize', () => {
114114
assert.ok(str.includes('src="data:image/png;base64,'));
115115
});
116116

117+
test('Supports dynamic attribute sanitization', () => {
118+
const html = '<div title="a" other="1">text1</div><div title="b" other="2">text2</div>';
119+
const result = sanitizeHtml(html, {
120+
allowedAttributes: {
121+
override: [
122+
{
123+
attributeName: 'title',
124+
shouldKeep: (_el, data) => {
125+
return data.attrValue.includes('b');
126+
}
127+
}
128+
]
129+
}
130+
});
131+
const str = result.toString();
132+
assert.strictEqual(str, '<div>text1</div><div title="b">text2</div>');
133+
});
134+
135+
test('Supports changing attributes in dynamic sanitization', () => {
136+
const html = '<div title="abc" other="1">text1</div><div title="xyz" other="2">text2</div>';
137+
const result = sanitizeHtml(html, {
138+
allowedAttributes: {
139+
override: [
140+
{
141+
attributeName: 'title',
142+
shouldKeep: (_el, data) => {
143+
if (data.attrValue === 'abc') {
144+
return false;
145+
}
146+
return data.attrValue + data.attrValue;
147+
}
148+
}
149+
]
150+
}
151+
});
152+
// xyz title should be preserved and doubled
153+
assert.strictEqual(result.toString(), '<div>text1</div><div title="xyzxyz">text2</div>');
154+
});
155+
117156
suite('replaceWithPlaintext', () => {
118157

119158
test('replaces unsupported tags with plaintext representation', () => {

src/vs/platform/debug/common/extensionHostDebug.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,6 @@ export interface IExtensionHostDebugService {
4848
readonly onTerminateSession: Event<ITerminateSessionEvent>;
4949

5050
openExtensionDevelopmentHostWindow(args: string[], debugRenderer: boolean): Promise<IOpenExtensionWindowResult>;
51+
52+
attachToCurrentWindowRenderer(windowId: number): Promise<IOpenExtensionWindowResult>;
5153
}

src/vs/platform/debug/common/extensionHostDebugIpc.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,8 @@ export class ExtensionHostDebugChannelClient extends Disposable implements IExte
8989
openExtensionDevelopmentHostWindow(args: string[], debugRenderer: boolean): Promise<IOpenExtensionWindowResult> {
9090
return this.channel.call('openExtensionDevelopmentHostWindow', [args, debugRenderer]);
9191
}
92+
93+
attachToCurrentWindowRenderer(windowId: number): Promise<IOpenExtensionWindowResult> {
94+
return this.channel.call('attachToCurrentWindowRenderer', [windowId]);
95+
}
9296
}

src/vs/platform/debug/electron-main/extensionHostDebugIpc.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { BrowserWindow } from 'electron';
67
import { AddressInfo, createServer } from 'net';
7-
import { IOpenExtensionWindowResult } from '../common/extensionHostDebug.js';
8-
import { ExtensionHostDebugBroadcastChannel } from '../common/extensionHostDebugIpc.js';
98
import { OPTIONS, parseArgs } from '../../environment/node/argv.js';
109
import { IWindowsMainService, OpenContext } from '../../windows/electron-main/windows.js';
10+
import { IOpenExtensionWindowResult } from '../common/extensionHostDebug.js';
11+
import { ExtensionHostDebugBroadcastChannel } from '../common/extensionHostDebugIpc.js';
1112

1213
export class ElectronExtensionHostDebugBroadcastChannel<TContext> extends ExtensionHostDebugBroadcastChannel<TContext> {
1314

@@ -20,11 +21,22 @@ export class ElectronExtensionHostDebugBroadcastChannel<TContext> extends Extens
2021
override call(ctx: TContext, command: string, arg?: any): Promise<any> {
2122
if (command === 'openExtensionDevelopmentHostWindow') {
2223
return this.openExtensionDevelopmentHostWindow(arg[0], arg[1]);
24+
} else if (command === 'attachToCurrentWindowRenderer') {
25+
return this.attachToCurrentWindowRenderer(arg[0]);
2326
} else {
2427
return super.call(ctx, command, arg);
2528
}
2629
}
2730

31+
private async attachToCurrentWindowRenderer(windowId: number): Promise<IOpenExtensionWindowResult> {
32+
const codeWindow = this.windowsMainService.getWindowById(windowId);
33+
if (!codeWindow?.win) {
34+
return { success: false };
35+
}
36+
37+
return this.openCdp(codeWindow.win);
38+
}
39+
2840
private async openExtensionDevelopmentHostWindow(args: string[], debugRenderer: boolean): Promise<IOpenExtensionWindowResult> {
2941
const pargs = parseArgs(args, OPTIONS);
3042
pargs.debugRenderer = debugRenderer;
@@ -50,6 +62,10 @@ export class ElectronExtensionHostDebugBroadcastChannel<TContext> extends Extens
5062
return { success: true };
5163
}
5264

65+
return this.openCdp(win);
66+
}
67+
68+
private async openCdp(win: BrowserWindow): Promise<IOpenExtensionWindowResult> {
5369
const debug = win.webContents.debugger;
5470

5571
let listeners = debug.isAttached() ? Infinity : 0;

src/vs/workbench/api/common/extHostChatSessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
7575
commands.registerArgumentProcessor({
7676
processArgument: (arg) => {
7777
if (arg && arg.$mid === MarshalledId.ChatSessionContext) {
78-
const id = arg.id;
78+
const id = arg.session.id;
7979
const sessionContent = this._sessionMap.get(id);
8080
if (sessionContent) {
8181
return sessionContent;

src/vs/workbench/contrib/chat/browser/actions/chatActions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ export function registerChatActions() {
580580

581581
const ckey = contextKeyService.createKey('chatSessionType', session.providerType);
582582
const actions = menuService.getMenuActions(MenuId.ChatSessionsMenu, contextKeyService);
583-
const menuActions = getContextMenuActions(actions, 'navigation');
583+
const menuActions = getContextMenuActions(actions, 'inline');
584584
ckey.reset();
585585

586586
// Use primary actions if available, otherwise fall back to secondary actions
@@ -756,7 +756,7 @@ export function registerChatActions() {
756756
const contextItem = context.item as ICodingAgentPickerItem;
757757
commandService.executeCommand(buttonItem.id, {
758758
uri: contextItem.uri,
759-
session: contextItem.session,
759+
session: contextItem.session?.session,
760760
$mid: MarshalledId.ChatSessionContext
761761
});
762762

src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface IChatConfirmationWidgetOptions {
4141
title: string | IMarkdownString;
4242
subtitle?: string | IMarkdownString;
4343
buttons: IChatConfirmationButton[];
44-
toolbarData?: { arg: any; partType: string };
44+
toolbarData?: { arg: any; partType: string; partSource?: string };
4545
}
4646

4747
export class ChatQueryTitlePart extends Disposable {
@@ -209,7 +209,10 @@ abstract class BaseChatConfirmationWidget extends Disposable {
209209

210210
// Create toolbar if actions are provided
211211
if (options?.toolbarData) {
212-
const overlay = contextKeyService.createOverlay([['chatConfirmationPartType', options.toolbarData.partType]]);
212+
const overlay = contextKeyService.createOverlay([
213+
['chatConfirmationPartType', options.toolbarData.partType],
214+
['chatConfirmationPartSource', options.toolbarData.partSource],
215+
]);
213216
const nestedInsta = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, overlay])));
214217
this._register(nestedInsta.createInstance(
215218
MenuWorkbenchToolBar,

src/vs/workbench/contrib/chat/browser/chatContentParts/chatElicitationContentPart.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class ChatElicitationContentPart extends Disposable implements IChatConte
3131
{ label: elicitation.acceptButtonLabel, data: true },
3232
{ label: elicitation.rejectButtonLabel, data: false, isSecondary: true },
3333
];
34-
const confirmationWidget = this._register(this.instantiationService.createInstance(ChatConfirmationWidget, context.container, { title: elicitation.title, subtitle: elicitation.subtitle, buttons, message: this.getMessageToRender(elicitation), toolbarData: { partType: elicitation.source ? `${elicitation.source.type}Elicitation` : 'elicitation', arg: elicitation } }));
34+
const confirmationWidget = this._register(this.instantiationService.createInstance(ChatConfirmationWidget, context.container, { title: elicitation.title, subtitle: elicitation.subtitle, buttons, message: this.getMessageToRender(elicitation), toolbarData: { partType: 'elicitation', partSource: elicitation.source?.type, arg: elicitation } }));
3535
confirmationWidget.setShowButtons(elicitation.state === 'pending');
3636

3737
this._register(elicitation.onDidRequestHide(() => this.domNode.remove()));

0 commit comments

Comments
 (0)