Skip to content

Commit

Permalink
Render html as plaintext when html not supported (microsoft#213265)
Browse files Browse the repository at this point in the history
* Support rendering unsupported html tags as plaintext for chat

* Render html as plaintext when html not supported

* Add comment and test for trusted domains
  • Loading branch information
roblourens authored and andremmsilva committed May 26, 2024
1 parent 9c6dae2 commit 13f374a
Show file tree
Hide file tree
Showing 14 changed files with 280 additions and 14 deletions.
63 changes: 57 additions & 6 deletions src/vs/base/browser/markdownRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface MarkdownRenderOptions extends FormattedTextRenderOptions {
readonly asyncRenderCallback?: () => void;
readonly fillInIncompleteTokens?: boolean;
readonly remoteImageIsAllowed?: (uri: URI) => boolean;
readonly sanitizerOptions?: ISanitizerOptions;
}

export interface ISanitizerOptions {
replaceWithPlaintext?: boolean;
allowedTags?: string[];
}

const defaultMarkedRenderers = Object.freeze({
Expand Down Expand Up @@ -221,6 +227,10 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende
// We always pass the output through dompurify after this so that we don't rely on
// marked for sanitization.
markedOptions.sanitizer = (html: string): string => {
if (options.sanitizerOptions?.replaceWithPlaintext) {
return escape(html);
}

const match = markdown.isTrusted ? html.match(/^(<span[^>]+>)|(<\/\s*span>)$/) : undefined;
return match ? html : '';
};
Expand Down Expand Up @@ -261,7 +271,7 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende
}

const htmlParser = new DOMParser();
const markdownHtmlDoc = htmlParser.parseFromString(sanitizeRenderedMarkdown(markdown, renderedMarkdown) as unknown as string, 'text/html');
const markdownHtmlDoc = htmlParser.parseFromString(sanitizeRenderedMarkdown({ isTrusted: markdown.isTrusted, ...options.sanitizerOptions }, renderedMarkdown) as unknown as string, 'text/html');

markdownHtmlDoc.body.querySelectorAll('img, audio, video, source')
.forEach(img => {
Expand Down Expand Up @@ -306,7 +316,7 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende
}
});

element.innerHTML = sanitizeRenderedMarkdown(markdown, markdownHtmlDoc.body.innerHTML) as unknown as string;
element.innerHTML = sanitizeRenderedMarkdown({ isTrusted: markdown.isTrusted, ...options.sanitizerOptions }, markdownHtmlDoc.body.innerHTML) as unknown as string;

if (codeBlocks.length > 0) {
Promise.all(codeBlocks).then((tuples) => {
Expand Down Expand Up @@ -378,8 +388,14 @@ function resolveWithBaseUri(baseUri: URI, href: string): string {
}
}

interface IInternalSanitizerOptions extends ISanitizerOptions {
isTrusted?: boolean | MarkdownStringTrustedOptions;
}

const selfClosingTags = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'];

function sanitizeRenderedMarkdown(
options: { isTrusted?: boolean | MarkdownStringTrustedOptions },
options: IInternalSanitizerOptions,
renderedMarkdown: string,
): TrustedHTML {
const { config, allowedSchemes } = getSanitizerOptions(options);
Expand Down Expand Up @@ -410,10 +426,45 @@ function sanitizeRenderedMarkdown(
if (e.tagName === 'input') {
if (element.attributes.getNamedItem('type')?.value === 'checkbox') {
element.setAttribute('disabled', '');
} else {
} else if (!options.replaceWithPlaintext) {
element.parentElement?.removeChild(element);
}
}

if (options.replaceWithPlaintext && !e.allowedTags[e.tagName] && e.tagName !== 'body') {
if (element.parentElement) {
let startTagText: string;
let endTagText: string | undefined;
if (e.tagName === '#comment') {
startTagText = `<!--${element.textContent}-->`;
} else {
const isSelfClosing = selfClosingTags.includes(e.tagName);
const attrString = element.attributes.length ?
' ' + Array.from(element.attributes)
.map(attr => `${attr.name}="${attr.value}"`)
.join(' ')
: '';
startTagText = `<${e.tagName}${attrString}>`;
if (!isSelfClosing) {
endTagText = `</${e.tagName}>`;
}
}

const fragment = document.createDocumentFragment();
const textNode = element.parentElement.ownerDocument.createTextNode(startTagText);
fragment.appendChild(textNode);
const endTagTextNode = endTagText ? element.parentElement.ownerDocument.createTextNode(endTagText) : undefined;
while (element.firstChild) {
fragment.appendChild(element.firstChild);
}

if (endTagTextNode) {
fragment.appendChild(endTagTextNode);
}

element.parentElement.replaceChild(fragment, element);
}
}
}));

store.add(DOM.hookDomPurifyHrefAndSrcSanitizer(allowedSchemes));
Expand Down Expand Up @@ -451,7 +502,7 @@ export const allowedMarkdownAttr = [
'start',
];

function getSanitizerOptions(options: { readonly isTrusted?: boolean | MarkdownStringTrustedOptions }): { config: dompurify.Config; allowedSchemes: string[] } {
function getSanitizerOptions(options: IInternalSanitizerOptions): { config: dompurify.Config; allowedSchemes: string[] } {
const allowedSchemes = [
Schemas.http,
Schemas.https,
Expand All @@ -473,7 +524,7 @@ function getSanitizerOptions(options: { readonly isTrusted?: boolean | MarkdownS
// Since we have our own sanitize function for marked, it's possible we missed some tag so let dompurify make sure.
// HTML tags that can result from markdown are from reading https://spec.commonmark.org/0.29/
// HTML table tags that can result from markdown are from https://github.github.com/gfm/#tables-extension-
ALLOWED_TAGS: [...DOM.basicMarkupHtmlTags],
ALLOWED_TAGS: options.allowedTags ?? [...DOM.basicMarkupHtmlTags],
ALLOWED_ATTR: allowedMarkdownAttr,
ALLOW_UNKNOWN_PROTOCOLS: true,
},
Expand Down
14 changes: 6 additions & 8 deletions src/vs/workbench/contrib/chat/browser/chatListRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*--------------------------------------------------------------------------------------------*/

import * as dom from 'vs/base/browser/dom';
import { renderFormattedText } from 'vs/base/browser/formattedTextRenderer';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems';
import { alert } from 'vs/base/browser/ui/aria/aria';
import { Button } from 'vs/base/browser/ui/button/button';
Expand All @@ -21,6 +23,7 @@ import { Codicon } from 'vs/base/common/codicons';
import { Emitter, Event } from 'vs/base/common/event';
import { FuzzyScore } from 'vs/base/common/filters';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { KeyCode } from 'vs/base/common/keyCodes';
import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { ResourceMap } from 'vs/base/common/map';
import { FileAccess, Schemas, matchesSomeScheme } from 'vs/base/common/network';
Expand Down Expand Up @@ -68,19 +71,16 @@ import { ChatAgentLocation, IChatAgentMetadata } from 'vs/workbench/contrib/chat
import { CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING, CONTEXT_REQUEST, CONTEXT_RESPONSE, CONTEXT_RESPONSE_DETECTED_AGENT_COMMAND, CONTEXT_RESPONSE_FILTERED, CONTEXT_RESPONSE_VOTE } from 'vs/workbench/contrib/chat/common/chatContextKeys';
import { IChatProgressRenderableResponseContent, IChatTextEditGroup } from 'vs/workbench/contrib/chat/common/chatModel';
import { chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatCommandButton, IChatConfirmation, IChatContentReference, IChatFollowup, IChatProgressMessage, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatTask, IChatWarningMessage, ChatAgentVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { ChatAgentVoteDirection, IChatCommandButton, IChatConfirmation, IChatContentReference, IChatFollowup, IChatProgressMessage, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatTask, IChatWarningMessage } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
import { IChatProgressMessageRenderData, IChatRenderData, IChatResponseMarkdownRenderData, IChatResponseViewModel, IChatTaskRenderData, IChatWelcomeMessageViewModel, isRequestVM, isResponseVM, isWelcomeVM } from 'vs/workbench/contrib/chat/common/chatViewModel';
import { IWordCountResult, getNWords } from 'vs/workbench/contrib/chat/common/chatWordCounter';
import { createFileIconThemableTreeContainerScope } from 'vs/workbench/contrib/files/browser/views/explorerView';
import { IFilesConfiguration } from 'vs/workbench/contrib/files/common/files';
import { ITrustedDomainService } from 'vs/workbench/contrib/url/browser/trustedDomainService';
import { IMarkdownVulnerability, annotateSpecialMarkdownContent } from '../common/annotations';
import { CodeBlockModelCollection } from '../common/codeBlockModelCollection';
import { IChatListItemRendererOptions } from './chat';
import { renderFormattedText } from 'vs/base/browser/formattedTextRenderer';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { KeyCode } from 'vs/base/common/keyCodes';
import { ChatMarkdownRenderer } from 'vs/workbench/contrib/chat/browser/chatMarkdownRenderer';

const $ = dom.$;

Expand Down Expand Up @@ -160,13 +160,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
@ICommandService private readonly commandService: ICommandService,
@ITextModelService private readonly textModelService: ITextModelService,
@IModelService private readonly modelService: IModelService,
@ITrustedDomainService private readonly trustedDomainService: ITrustedDomainService,
@IHoverService private readonly hoverService: IHoverService,
@IChatService private readonly chatService: IChatService,
) {
super();

this.renderer = this._register(this.instantiationService.createInstance(MarkdownRenderer, {}));
this.renderer = this._register(this.instantiationService.createInstance(ChatMarkdownRenderer, undefined));
this.markdownDecorationsRenderer = this.instantiationService.createInstance(ChatMarkdownDecorationsRenderer);
this._editorPool = this._register(this.instantiationService.createInstance(EditorPool, editorOptions, delegate, overflowWidgetsDomNode));
this._diffEditorPool = this._register(this.instantiationService.createInstance(DiffEditorPool, editorOptions, delegate, overflowWidgetsDomNode));
Expand Down Expand Up @@ -1104,7 +1103,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
const codeblocks: IChatCodeBlockInfo[] = [];
let codeBlockIndex = 0;
const result = this.renderer.render(markdown, {
remoteImageIsAllowed: (uri) => this.trustedDomainService.isValid(uri),
fillInIncompleteTokens,
codeBlockRendererSync: (languageId, text) => {
const index = codeBlockIndex++;
Expand Down
79 changes: 79 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { MarkdownRenderOptions, MarkedOptions } from 'vs/base/browser/markdownRenderer';
import { IMarkdownString } from 'vs/base/common/htmlContent';
import { IMarkdownRendererOptions, IMarkdownRenderResult, MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer';
import { ILanguageService } from 'vs/editor/common/languages/language';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { ITrustedDomainService } from 'vs/workbench/contrib/url/browser/trustedDomainService';

const allowedHtmlTags = [
'b',
'blockquote',
'br',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'li',
'ol',
'p',
'pre',
'strong',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'ul',
'a',
'img',

// Not in the official list, but used for codicons and other vscode markdown extensions
'span',
];

/**
* This wraps the MarkdownRenderer and applies sanitizer options needed for Chat.
*/
export class ChatMarkdownRenderer extends MarkdownRenderer {
constructor(
options: IMarkdownRendererOptions | undefined,
@ILanguageService languageService: ILanguageService,
@IOpenerService openerService: IOpenerService,
@ITrustedDomainService private readonly trustedDomainService: ITrustedDomainService,
) {
super(options ?? {}, languageService, openerService);
}

override render(markdown: IMarkdownString | undefined, options?: MarkdownRenderOptions, markedOptions?: MarkedOptions): IMarkdownRenderResult {
options = {
...options,
remoteImageIsAllowed: (uri) => this.trustedDomainService.isValid(uri),
sanitizerOptions: {
replaceWithPlaintext: true,
allowedTags: allowedHtmlTags,
}
};

const mdWithBody: IMarkdownString | undefined = (markdown && markdown.supportHtml) ?
{
...markdown,

// dompurify uses DOMParser, which strips leading comments. Wrapping it all in 'body' prevents this.
value: `<body>${markdown.value}</body>`,
}
: markdown;
return super.render(mdWithBody, options, markedOptions);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="rendered-markdown">&lt;!--[CDATA[&lt;div--&gt;content]]&gt;</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="rendered-markdown">&lt;!-- comment1 &lt;div&gt;&lt;/div&gt; --&gt;&lt;div&gt;content&lt;/div&gt;&lt;!-- comment2 --&gt;</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="rendered-markdown">1&lt;canvas&gt;2&lt;div&gt;3&lt;/div&gt;&lt;/canvas&gt;4</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="rendered-markdown">1&lt;div id="id1" style="display: none"&gt;2&lt;div id="my id 2"&gt;3&lt;/div&gt;&lt;/div&gt;4</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="rendered-markdown"><h1>heading</h1>
&lt;div&gt;
<ul>
<li><span>&lt;div&gt;<i>1</i>&lt;/div&gt;</span></li>
<li><b>hi</b></li>
</ul>
&lt;/div&gt;
<pre>&lt;canvas&gt;canvas here&lt;/canvas&gt;</pre>&lt;details&gt;&lt;/details&gt;</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="rendered-markdown"><img src="http://allowed.com/image.jpg"> &lt;div&gt;&lt;img src="http://disallowed.com/image.jpg"&gt;&lt;/div&gt;</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="rendered-markdown">&lt;area&gt;<hr><br>&lt;input type="text" value="test"&gt;</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
a
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="rendered-markdown"><h1>heading</h1>
<ul>
<li>1</li>
<li><b>hi</b></li>
</ul>
<pre><code>code here</code></pre></div>
Loading

0 comments on commit 13f374a

Please sign in to comment.