Repository: microsoft/vscode (TypeScript) — Bug Fix

Issue:
With `md.supportHtml = true`, an img tag on the markdown document cannot load the URI of a local resource
Version: 1.62.0-insider (Universal)
Commit: f961b92f0faec5c68124ab4d89922d38cf6f513d
Date: 2021-10-28T05:14:00.367Z
Electron: 13.5.1
Chrome: 91.0.4472.164
Node.js: 14.16.0
V8: 9.1.269.39-electron.0
OS: Darwin arm64 20.6.0


Setting `md.supportHtml = true`, on the markdown document of a completion item, an `img` tag cannot load a PNG file if the path given as a URI with `vscode.Uri.file`. If the path given as the path of a local filesystem, it can load the file.

Steps to Reproduce:

1. Execute https://github.com/tamuratak/vscode-extension-samples/tree/supporthtml_img_local/completions-sample
2. Execute `Trigger Suggest`
3. Only one of images cannot be loaded.

![スクリーンショット 2021-10-28 20 51 23](https://user-images.githubusercontent.com/10665499/139251098-47126baa-f74b-499c-8ef5-b7a2f7095e4e.png)

```ts
import * as vscode from 'vscode';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {

	const provider1 = vscode.languages.registerCompletionItemProvider('plaintext', {

		provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, context: vscode.CompletionContext) {

			const pngPath = path.join(__dirname, '..', 'a.png')
			const pngPathUriString = vscode.Uri.file(pngPath).toString()

			const snippetCompletion = new vscode.CompletionItem('Good part of the day');
			snippetCompletion.insertText = new vscode.SnippetString('Good ${1|morning,afternoon,evening|}. It is ${1}, right?');
			const md = new vscode.MarkdownString(`1 <img src="${pngPathUriString}"> 2 <img src="${pngPath}"> 3 ![a](${pngPathUriString}) 4 ![a](${pngPath})`);
			md.supportHtml = true
			snippetCompletion.documentation = md

			// return all completion items as array
			return [
				snippetCompletion
			];
		}
	});
	context.subscriptions.push(provider1);
}
```


Discussion:
@mjbvz Can I pick this up?
Sure, the relevant code where we fix image paths is: 

https://github.com/microsoft/vscode/blob/d209aa2b02f6cc99f3c985b23c99eb2346f9dbdb/src/vs/base/browser/markdownRenderer.ts#L112
For the markdown syntax images, if the URI schema is `file:`, that's being changed to `vscode-file:`. For the HTML tag images, the file URI remains as is.

The HTML image with `file:` URI doesn't load due to CSP violation.

### Raw
```Markdown
1 <img src=\"file:///workspaces/vscode-extension-samples/completions-sample/a.png\"> 2 <img src=\"/workspaces/vscode-extension-samples/completions-sample/a.png\"> 3 ![a](file:///workspaces/vscode-extension-samples/completions-sample/a.png) 4 ![a](/workspaces/vscode-extension-samples/completions-sample/a.png)
```

### Rendered Markdown
```
<p>1 <img src=\"file:///workspaces/vscode-extension-samples/completions-sample/a.png\"> 2 <img src=\"/workspaces/vscode-extension-samples/completions-sample/a.png\"> 3 <img src=\"vscode-file://vscode-app/workspaces/vscode-extension-samples/completions-sample/a.png\" alt=\"a\"> 4 <img src=\"/workspaces/vscode-extension-samples/completions-sample/a.png\" alt=\"a\"></p>
```
Yes `file` uris are blocked for security reasons. The problem is that raw html tags don't go through the transform that I linked to

One thing to try would be to look for image nodes in the rendered markdown and then transform their uris instead of relying on the markdown parser to handle the transform. Try looking at [DOMParser](https://developer.mozilla.org/en-US/docs/Web/API/DOMParser) for that

Resolving patch:
diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts
--- a/src/vs/base/browser/markdownRenderer.ts
+++ b/src/vs/base/browser/markdownRenderer.ts
@@ -71,20 +71,23 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende
 
 	const _href = function (href: string, isDomUri: boolean): string {
 		const data = markdown.uris && markdown.uris[href];
-		if (!data) {
-			return href; // no uri exists
-		}
 		let uri = URI.revive(data);
 		if (isDomUri) {
 			if (href.startsWith(Schemas.data + ':')) {
 				return href;
 			}
+			if (!uri) {
+				uri = URI.parse(href);
+			}
 			// this URI will end up as "src"-attribute of a dom node
 			// and because of that special rewriting needs to be done
 			// so that the URI uses a protocol that's understood by
 			// browsers (like http or https)
 			return FileAccess.asBrowserUri(uri).toString(true);
 		}
+		if (!uri) {
+			return href;
+		}
 		if (URI.parse(href).toString() === uri.toString()) {
 			return href; // no transformation performed
 		}
@@ -100,19 +103,12 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende
 	const withInnerHTML = new Promise<void>(c => signalInnerHTML = c);
 
 	const renderer = new marked.Renderer();
+
 	renderer.image = (href: string, title: string, text: string) => {
 		let dimensions: string[] = [];
 		let attributes: string[] = [];
 		if (href) {
 			({ href, dimensions } = parseHrefAndDimensions(href));
-			href = _href(href, true);
-			try {
-				const hrefAsUri = URI.parse(href);
-				if (options.baseUrl && hrefAsUri.scheme === Schemas.file) { // absolute or relative local path, or file: uri
-					href = resolvePath(options.baseUrl, href).toString();
-				}
-			} catch (err) { }
-
 			attributes.push(`src="${href}"`);
 		}
 		if (text) {
@@ -250,7 +246,26 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende
 		renderedMarkdown = elements.map(e => typeof e === 'string' ? e : e.outerHTML).join('');
 	}
 
-	element.innerHTML = sanitizeRenderedMarkdown(markdown, renderedMarkdown) as unknown as string;
+	const htmlParser = new DOMParser();
+	const markdownHtmlDoc = htmlParser.parseFromString(sanitizeRenderedMarkdown(markdown, renderedMarkdown) as unknown as string, 'text/html');
+
+	markdownHtmlDoc.body.querySelectorAll('img')
+		.forEach(img => {
+			if (img.src) {
+				let href = _href(img.src, true);
+
+				try {
+					const hrefAsUri = URI.parse(href);
+					if (options.baseUrl && hrefAsUri.scheme === Schemas.file) { // absolute or relative local path, or file: uri
+						href = resolvePath(options.baseUrl, href).toString();
+					}
+				} catch (err) { }
+
+				img.src = href;
+			}
+		});
+
+	element.innerHTML = sanitizeRenderedMarkdown(markdown, markdownHtmlDoc.body.innerHTML) as unknown as string;
 
 	// signal that async code blocks can be now be inserted
 	signalInnerHTML!();


Test patch:
diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts
--- a/src/vs/base/test/browser/markdownRenderer.test.ts
+++ b/src/vs/base/test/browser/markdownRenderer.test.ts
@@ -56,6 +56,11 @@ suite('MarkdownRenderer', () => {
 			const result: HTMLElement = renderMarkdown({ value: `![image](http://example.com/cat.gif|height=200,width=100 'caption')` }).element;
 			assertNodeEquals(result, `<div><p><img height="200" width="100" title="caption" alt="image" src="http://example.com/cat.gif"></p></div>`);
 		});
+
+		test('image with file uri should render as same origin uri', () => {
+			const result: HTMLElement = renderMarkdown({ value: `![image](file:///images/cat.gif)` }).element;
+			assertNodeEquals(result, '<div><p><img src="vscode-file://vscode-app/images/cat.gif" alt="image"></p></div>');
+		});
 	});
 
 	suite('Code block renderer', () => {
@@ -139,7 +144,7 @@ suite('MarkdownRenderer', () => {
 			mds.appendMarkdown(`[$(zap)-link](#link)`);
 
 			let result: HTMLElement = renderMarkdown(mds).element;
-			assert.strictEqual(result.innerHTML, `<p><a title="#link" data-href="#link" href="#"><span class="codicon codicon-zap"></span>-link</a></p>`);
+			assert.strictEqual(result.innerHTML, `<p><a href="#" data-href="#link" title="#link"><span class="codicon codicon-zap"></span>-link</a></p>`);
 		});
 
 		test('render icon in table', () => {
@@ -159,7 +164,7 @@ suite('MarkdownRenderer', () => {
 </thead>
 <tbody><tr>
 <td><span class="codicon codicon-zap"></span></td>
-<td><a title="#link" data-href="#link" href="#"><span class="codicon codicon-zap"></span>-link</a></td>
+<td><a href="#" data-href="#link" title="#link"><span class="codicon codicon-zap"></span>-link</a></td>
 </tr>
 </tbody></table>
 `);
@@ -251,5 +256,21 @@ suite('MarkdownRenderer', () => {
 			const result = renderMarkdown(mds).element;
 			assert.strictEqual(result.innerHTML, `<p>a&lt;b&gt;b&lt;/b&gt;c</p>`);
 		});
+
+		test('Should render html images', () => {
+			const mds = new MarkdownString(undefined, { supportHtml: true });
+			mds.appendMarkdown(`<img src="http://example.com/cat.gif">`);
+
+			const result = renderMarkdown(mds).element;
+			assert.strictEqual(result.innerHTML, `<img src="http://example.com/cat.gif">`);
+		});
+
+		test('Should render html images with file uri as same origin uri', () => {
+			const mds = new MarkdownString(undefined, { supportHtml: true });
+			mds.appendMarkdown(`<img src="file:///images/cat.gif">`);
+
+			const result = renderMarkdown(mds).element;
+			assert.strictEqual(result.innerHTML, `<img src="vscode-file://vscode-app/images/cat.gif">`);
+		});
 	});
 });