From cbf9ae23ed165078e98e00a658a218ce690a6dfd Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Tue, 16 Jan 2018 12:50:14 -0800 Subject: [PATCH] Allow loading webview outside of file: origin (#41698) * Allow loading webview outside of file: origin **Problem** Webviews are currently always loaded from a file on the disk. This results in the webview running in the file origin, potentially allowing it to access any file on disk. If a webview fails to sanitize workspace or remote input, untrusted code could potentially access files on the user's system. **Fix** Add a new option to serve the webview out of a "data:" uri instead. This prevents access to `file://` resources. In order to allow webviews to still load resources from disk, add a new protocol called `vscode-core-resource://` that only allows access to resources inside of the vscode directory. Moves extension pages and our release notes to use this new option. These already are pretty locked down. We cannot move the htmlpreview command to use this option as it would break a huge number of existing extensions, however the new webview API will always have this new option enabled. * Shorted protocol name --- src/vs/code/electron-main/app.ts | 12 +++- .../extensions/browser/extensionEditor.ts | 21 ++++--- .../parts/html/browser/htmlPreviewPart.ts | 14 +++-- .../parts/html/browser/webview-pre.js | 4 +- .../workbench/parts/html/browser/webview.ts | 57 +++++++++++++++---- .../electron-browser/releaseNotesEditor.ts | 23 +++++--- 6 files changed, 95 insertions(+), 36 deletions(-) diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index ca4a569c0f6..faffd00b753 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -126,8 +126,16 @@ export class CodeApplication { } }); - const isValidWebviewSource = (source: string) => - !source || (URI.parse(source.toLowerCase()).toString() as any).startsWith(URI.file(this.environmentService.appRoot.toLowerCase()).toString()); + const isValidWebviewSource = (source: string): boolean => { + if (!source) { + return false; + } + if (source === 'data:text/html;charset=utf-8,%3C%21DOCTYPE%20html%3E%0D%0A%3Chtml%20lang%3D%22en%22%20style%3D%22width%3A%20100%25%3B%20height%3A%20100%25%22%3E%0D%0A%3Chead%3E%0D%0A%09%3Ctitle%3EVirtual%20Document%3C%2Ftitle%3E%0D%0A%3C%2Fhead%3E%0D%0A%3Cbody%20style%3D%22margin%3A%200%3B%20overflow%3A%20hidden%3B%20width%3A%20100%25%3B%20height%3A%20100%25%22%3E%0D%0A%3C%2Fbody%3E%0D%0A%3C%2Fhtml%3E') { + return true; + } + const srcUri: any = URI.parse(source.toLowerCase()).toString(); + return srcUri.startsWith(URI.file(this.environmentService.appRoot.toLowerCase()).toString()); + }; app.on('web-contents-created', (_event: any, contents) => { contents.on('will-attach-webview', (event: Electron.Event, webPreferences, params) => { diff --git a/src/vs/workbench/parts/extensions/browser/extensionEditor.ts b/src/vs/workbench/parts/extensions/browser/extensionEditor.ts index 95a51e0fadc..43a5d963e96 100644 --- a/src/vs/workbench/parts/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/parts/extensions/browser/extensionEditor.ts @@ -52,6 +52,7 @@ import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/edi import { KeybindingsRegistry } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { Color } from 'vs/base/common/color'; import { WorkbenchTree, IListService } from 'vs/platform/list/browser/listService'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; /** A context key that is set when an extension editor webview has focus. */ export const KEYBINDING_CONTEXT_EXTENSIONEDITOR_WEBVIEW_FOCUS = new RawContextKey('extensionEditorWebviewFocus', undefined); @@ -62,13 +63,17 @@ export const KEYBINDING_CONTEXT_EXTENSIONEDITOR_FIND_WIDGET_INPUT_FOCUSED = new /** A context key that is set when the find widget find input in extension editor webview is not focused. */ export const KEYBINDING_CONTEXT_EXTENSIONEDITOR_FIND_WIDGET_INPUT_NOT_FOCUSED: ContextKeyExpr = KEYBINDING_CONTEXT_EXTENSIONEDITOR_FIND_WIDGET_INPUT_FOCUSED.toNegated(); -function renderBody(body: string): string { +function renderBody( + body: string, + environmentService: IEnvironmentService +): string { + const styleSheetPath = require.toUrl('./media/markdown.css').replace('file://' + environmentService.appRoot, 'vscode-core-resource://'); return ` - - + + @@ -194,7 +199,9 @@ export class ExtensionEditor extends BaseEditor { @IPartService private partService: IPartService, @IContextViewService private contextViewService: IContextViewService, @IContextKeyService private contextKeyService: IContextKeyService, - @IExtensionTipsService private extensionTipsService: IExtensionTipsService + @IExtensionTipsService private extensionTipsService: IExtensionTipsService, + @IEnvironmentService private environmentService: IEnvironmentService + ) { super(ExtensionEditor.ID, telemetryService, themeService); this.disposables = []; @@ -408,12 +415,12 @@ export class ExtensionEditor extends BaseEditor { private openMarkdown(content: TPromise, noContentCopy: string) { return this.loadContents(() => content .then(marked.parse) - .then(renderBody) + .then(content => renderBody(content, this.environmentService)) .then(removeEmbeddedSVGs) .then(body => { const allowedBadgeProviders = this.extensionsWorkbenchService.allowedBadgeProviders; - const webViewOptions = allowedBadgeProviders.length > 0 ? { allowScripts: false, allowSvgs: false, svgWhiteList: allowedBadgeProviders } : undefined; - this.activeWebview = new WebView(this.content, this.partService.getContainer(Parts.EDITOR_PART), this.contextViewService, this.contextKey, this.findInputFocusContextKey, webViewOptions); + const webViewOptions = allowedBadgeProviders.length > 0 ? { allowScripts: false, allowSvgs: false, svgWhiteList: allowedBadgeProviders } : {}; + this.activeWebview = new WebView(this.content, this.partService.getContainer(Parts.EDITOR_PART), this.environmentService, this.contextViewService, this.contextKey, this.findInputFocusContextKey, webViewOptions, false); const removeLayoutParticipant = arrays.insert(this.layoutParticipants, this.activeWebview); this.contentDisposables.push(toDisposable(removeLayoutParticipant)); diff --git a/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts b/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts index 9674b857ced..edee199e773 100644 --- a/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts +++ b/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts @@ -25,6 +25,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import Webview, { WebviewOptions } from './webview'; import { IStorageService } from 'vs/platform/storage/common/storage'; import { WebviewEditor } from './webviewEditor'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; /** @@ -46,13 +47,14 @@ export class HtmlPreviewPart extends WebviewEditor { constructor( @ITelemetryService telemetryService: ITelemetryService, - @ITextModelService private textModelResolverService: ITextModelService, @IThemeService themeService: IThemeService, - @IOpenerService private readonly openerService: IOpenerService, - @IPartService private partService: IPartService, @IStorageService storageService: IStorageService, - @IContextViewService private _contextViewService: IContextViewService, - @IContextKeyService contextKeyService: IContextKeyService + @IContextKeyService contextKeyService: IContextKeyService, + @ITextModelService private readonly textModelResolverService: ITextModelService, + @IOpenerService private readonly openerService: IOpenerService, + @IPartService private readonly partService: IPartService, + @IContextViewService private readonly _contextViewService: IContextViewService, + @IEnvironmentService private readonly _environmentService: IEnvironmentService ) { super(HtmlPreviewPart.ID, telemetryService, themeService, storageService, contextKeyService); } @@ -84,7 +86,7 @@ export class HtmlPreviewPart extends WebviewEditor { webviewOptions = this.input.options; } - this._webview = new Webview(this.content, this.partService.getContainer(Parts.EDITOR_PART), this._contextViewService, this.contextKey, this.findInputFocusContextKey, webviewOptions); + this._webview = new Webview(this.content, this.partService.getContainer(Parts.EDITOR_PART), this._environmentService, this._contextViewService, this.contextKey, this.findInputFocusContextKey, webviewOptions, true); if (this.input && this.input instanceof HtmlInput) { const state = this.loadViewState(this.input.getResource()); this.scrollYPercentage = state ? state.scrollYPercentage : 0; diff --git a/src/vs/workbench/parts/html/browser/webview-pre.js b/src/vs/workbench/parts/html/browser/webview-pre.js index ebb69c9bd46..17c09cdc32f 100644 --- a/src/vs/workbench/parts/html/browser/webview-pre.js +++ b/src/vs/workbench/parts/html/browser/webview-pre.js @@ -264,7 +264,7 @@ contentWindow.addEventListener('scroll', handleInnerScroll); pendingMessages.forEach(function (data) { - contentWindow.postMessage(data, document.location.origin); + contentWindow.postMessage(data, '*'); }); pendingMessages = []; } @@ -303,7 +303,7 @@ } else { const target = getActiveFrame(); if (target) { - target.contentWindow.postMessage(data, document.location.origin); + target.contentWindow.postMessage(data, '*'); } } }); diff --git a/src/vs/workbench/parts/html/browser/webview.ts b/src/vs/workbench/parts/html/browser/webview.ts index c4954e1f376..b46ee754598 100644 --- a/src/vs/workbench/parts/html/browser/webview.ts +++ b/src/vs/workbench/parts/html/browser/webview.ts @@ -14,6 +14,9 @@ import { ITheme, LIGHT, DARK } from 'vs/platform/theme/common/themeService'; import { WebviewFindWidget } from './webviewFindWidget'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { normalize, join } from 'vs/base/common/paths'; +import { startsWith } from 'vs/base/common/strings'; export interface WebviewElementFindInPageOptions { forward?: boolean; @@ -39,8 +42,6 @@ export interface WebviewOptions { } export default class Webview { - private static index: number = 0; - private readonly _webview: Electron.WebviewTag; private _ready: Promise; private _disposables: IDisposable[] = []; @@ -55,13 +56,15 @@ export default class Webview { constructor( private readonly parent: HTMLElement, private readonly _styleElement: Element, - @IContextViewService private readonly _contextViewService: IContextViewService, + private readonly _environmentService: IEnvironmentService, + private readonly _contextViewService: IContextViewService, private readonly _contextKey: IContextKey, private readonly _findInputContextKey: IContextKey, - private _options: WebviewOptions = {}, + private _options: WebviewOptions, + useSameOriginForRoot: boolean ) { this._webview = document.createElement('webview'); - this._webview.setAttribute('partition', this._options.allowSvgs ? 'webview' : `webview${Webview.index++}`); + this._webview.setAttribute('partition', this._options.allowSvgs ? 'webview' : `webview${Date.now()}`); // disable auxclick events (see https://developers.google.com/web/updates/2016/10/auxclick) this._webview.setAttribute('disableblinkfeatures', 'Auxclick'); @@ -75,7 +78,7 @@ export default class Webview { this._webview.style.outline = '0'; this._webview.preload = require.toUrl('./webview-pre.js'); - this._webview.src = require.toUrl('./webview.html'); + this._webview.src = useSameOriginForRoot ? require.toUrl('./webview.html') : 'data:text/html;charset=utf-8,%3C%21DOCTYPE%20html%3E%0D%0A%3Chtml%20lang%3D%22en%22%20style%3D%22width%3A%20100%25%3B%20height%3A%20100%25%22%3E%0D%0A%3Chead%3E%0D%0A%09%3Ctitle%3EVirtual%20Document%3C%2Ftitle%3E%0D%0A%3C%2Fhead%3E%0D%0A%3Cbody%20style%3D%22margin%3A%200%3B%20overflow%3A%20hidden%3B%20width%3A%20100%25%3B%20height%3A%20100%25%22%3E%0D%0A%3C%2Fbody%3E%0D%0A%3C%2Fhtml%3E'; this._ready = new Promise(resolve => { const subscription = addDisposableListener(this._webview, 'ipc-message', (event) => { @@ -89,9 +92,24 @@ export default class Webview { }); }); + if (!useSameOriginForRoot) { + let loaded = false; + this._disposables.push(addDisposableListener(this._webview, 'did-start-loading', () => { + if (loaded) { + return; + } + loaded = true; + + const contents = this._webview.getWebContents(); + if (contents && !contents.isDestroyed()) { + registerFileProtocol(contents, 'vscode-core-resource', this._environmentService.appRoot); + } + })); + } + if (!this._options.allowSvgs) { let loaded = false; - const subscription = addDisposableListener(this._webview, 'did-start-loading', () => { + this._disposables.push(addDisposableListener(this._webview, 'did-start-loading', () => { if (loaded) { return; } @@ -124,9 +142,7 @@ export default class Webview { } return callback({ cancel: false, responseHeaders: details.responseHeaders }); }); - }); - - this._disposables.push(subscription); + })); } this._disposables.push( @@ -397,3 +413,24 @@ export default class Webview { this._webviewFindWidget.showPreviousFindTerm(); } } + +function registerFileProtocol( + contents: Electron.WebContents, + protocol: string, + root: string +) { + contents.session.protocol.registerFileProtocol(protocol, (request, callback: any) => { + const requestPath = URI.parse(request.url).path; + const normalizedPath = normalize(join(root, requestPath)); + if (startsWith(normalizedPath, root)) { + callback({ path: normalizedPath }); + } else { + callback({ error: 'Cannot load resource outside of protocol root' }); + } + }, (error) => { + if (error) { + console.error('Failed to register protocol ' + protocol); + } + }); +} + diff --git a/src/vs/workbench/parts/update/electron-browser/releaseNotesEditor.ts b/src/vs/workbench/parts/update/electron-browser/releaseNotesEditor.ts index 27632cdde54..53e1440adbd 100644 --- a/src/vs/workbench/parts/update/electron-browser/releaseNotesEditor.ts +++ b/src/vs/workbench/parts/update/electron-browser/releaseNotesEditor.ts @@ -29,14 +29,19 @@ import { onUnexpectedError } from 'vs/base/common/errors'; import { addGAParameters } from 'vs/platform/telemetry/node/telemetryNodeUtils'; import { generateTokensCSSForColorMap } from 'vs/editor/common/modes/supports/tokenization'; -function renderBody(body: string, css: string): string { +function renderBody( + body: string, + css: string, + environmentService: IEnvironmentService +): string { + const styleSheetPath = require.toUrl('./media/markdown.css').replace('file://' + environmentService.appRoot, 'vscode-core-resource://'); return ` - - + + ${body} @@ -52,14 +57,14 @@ export class ReleaseNotesEditor extends WebviewEditor { constructor( @ITelemetryService telemetryService: ITelemetryService, - @IEnvironmentService private environmentService: IEnvironmentService, @IThemeService protected themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IContextKeyService contextKeyService: IContextKeyService, + @IEnvironmentService private environmentService: IEnvironmentService, @IOpenerService private openerService: IOpenerService, @IModeService private modeService: IModeService, @IPartService private partService: IPartService, - @IStorageService storageService: IStorageService, - @IContextViewService private _contextViewService: IContextViewService, - @IContextKeyService contextKeyService: IContextKeyService + @IContextViewService private _contextViewService: IContextViewService ) { super(ReleaseNotesEditor.ID, telemetryService, themeService, storageService, contextKeyService); } @@ -99,8 +104,8 @@ export class ReleaseNotesEditor extends WebviewEditor { const colorMap = TokenizationRegistry.getColorMap(); const css = generateTokensCSSForColorMap(colorMap); - const body = renderBody(marked(text, { renderer }), css); - this._webview = new WebView(this.content, this.partService.getContainer(Parts.EDITOR_PART), this._contextViewService, this.contextKey, this.findInputFocusContextKey); + const body = renderBody(marked(text, { renderer }), css, this.environmentService); + this._webview = new WebView(this.content, this.partService.getContainer(Parts.EDITOR_PART), this.environmentService, this._contextViewService, this.contextKey, this.findInputFocusContextKey, {}, false); if (this.input && this.input instanceof ReleaseNotesInput) { const state = this.loadViewState(this.input.version); if (state) {