From 4ebfc2fc18ad1ca65b44f5c1c7c95ad347367beb Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 2 Mar 2017 21:04:04 -0800 Subject: [PATCH] Add Enhanced Security Settings to Markdown Preview Adds enhanced security settings for the markdown preview. The new flow disable all scripts within the preview itself. Users can enable scripts on a per workspace basis. When a markdown document that uses scripts is loaded, a warning is shown inside the document itself. This warning triggers a new security selector quick pick which allows users to enable or disable enahanced security in the workspace. --- extensions/markdown/media/csp.js | 32 ++++++ extensions/markdown/media/main.js | 15 +-- extensions/markdown/media/markdown.css | 22 ++++ extensions/markdown/npm-shrinkwrap.json | 5 + extensions/markdown/package.json | 12 +- extensions/markdown/package.nls.json | 3 +- extensions/markdown/src/extension.ts | 105 +++++++++++++++++- .../markdown/src/previewContentProvider.ts | 56 +++++++--- extensions/markdown/tsconfig.json | 2 +- .../parts/html/browser/webview-pre.js | 66 ++++++----- 10 files changed, 259 insertions(+), 59 deletions(-) create mode 100644 extensions/markdown/media/csp.js diff --git a/extensions/markdown/media/csp.js b/extensions/markdown/media/csp.js new file mode 100644 index 00000000000..f43b14c8d77 --- /dev/null +++ b/extensions/markdown/media/csp.js @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +(function () { + const settings = JSON.parse(document.getElementById('vscode-markdown-preview-data').getAttribute('data-settings')); + const strings = JSON.parse(document.getElementById('vscode-markdown-preview-data').getAttribute('data-strings')); + + let didShow = false; + + document.addEventListener('securitypolicyviolation', () => { + if (didShow) { + return; + } + didShow = true; + const args = [settings.previewUri]; + + const notification = document.createElement('a'); + notification.innerText = strings.cspAlertMessageText; + notification.setAttribute('id', 'code-csp-warning'); + notification.setAttribute('title', strings.cspAlertMessageTitle); + + notification.setAttribute('role', 'button'); + notification.setAttribute('aria-label', strings.cspAlertMessageLabel); + notification.setAttribute('href', `command:markdown.showPreviewSecuritySelector?${encodeURIComponent(JSON.stringify(args))}`); + + document.body.appendChild(notification); + }); +}()); \ No newline at end of file diff --git a/extensions/markdown/media/main.js b/extensions/markdown/media/main.js index c4614d853aa..f927c0fa90f 100644 --- a/extensions/markdown/media/main.js +++ b/extensions/markdown/media/main.js @@ -89,7 +89,7 @@ function scrollToRevealSourceLine(line) { const {previous, next} = getElementsForSourceLine(line); marker.update(previous && previous.element); - if (previous && window.initialData.scrollPreviewWithEditorSelection) { + if (previous && settings.scrollPreviewWithEditorSelection) { let scrollTo = 0; if (next) { // Between two elements. Go to percentage offset between them. @@ -141,10 +141,11 @@ var scrollDisabled = true; var marker = new ActiveLineMarker(); + const settings = JSON.parse(document.getElementById('vscode-markdown-preview-data').getAttribute('data-settings')); window.onload = () => { - if (window.initialData.scrollPreviewWithEditorSelection) { - const initialLine = +window.initialData.line; + if (settings.scrollPreviewWithEditorSelection) { + const initialLine = +settings.line; if (!isNaN(initialLine)) { setTimeout(() => { scrollDisabled = true; @@ -172,7 +173,7 @@ })(), false); document.addEventListener('dblclick', event => { - if (!window.initialData.doubleClickToSwitchToEditor) { + if (!settings.doubleClickToSwitchToEditor) { return; } @@ -186,7 +187,7 @@ const offset = event.pageY; const line = getEditorLineNumberForPageOffset(offset); if (!isNaN(line)) { - const args = [window.initialData.source, line]; + const args = [settings.source, line]; window.parent.postMessage({ command: "did-click-link", data: `command:_markdown.didClick?${encodeURIComponent(JSON.stringify(args))}` @@ -194,14 +195,14 @@ } }); - if (window.initialData.scrollEditorWithPreview) { + if (settings.scrollEditorWithPreview) { window.addEventListener('scroll', throttle(() => { if (scrollDisabled) { scrollDisabled = false; } else { const line = getEditorLineNumberForPageOffset(window.scrollY); if (!isNaN(line)) { - const args = [window.initialData.source, line]; + const args = [settings.source, line]; window.parent.postMessage({ command: "did-click-link", data: `command:_markdown.revealLine?${encodeURIComponent(JSON.stringify(args))}` diff --git a/extensions/markdown/media/markdown.css b/extensions/markdown/media/markdown.css index 7cefe421dc6..dfb4666bd19 100644 --- a/extensions/markdown/media/markdown.css +++ b/extensions/markdown/media/markdown.css @@ -11,6 +11,28 @@ body { word-wrap: break-word; } +#code-csp-warning { + position: fixed; + top: 0; + right: 0; + color: white; + margin: 16px; + text-align: center; + font-size: 12px; + font-family: sans-serif; + background-color:#444444; + cursor: pointer; + padding: 6px; + box-shadow: 1px 1px 1px rgba(0,0,0,.25); +} + +#code-csp-warning:hover { + text-decoration: none; + background-color:#007acc; + box-shadow: 2px 2px 2px rgba(0,0,0,.25); +} + + body.scrollBeyondLastLine { margin-bottom: calc(100vh - 22px); } diff --git a/extensions/markdown/npm-shrinkwrap.json b/extensions/markdown/npm-shrinkwrap.json index 0f93176c555..de14b48e12d 100644 --- a/extensions/markdown/npm-shrinkwrap.json +++ b/extensions/markdown/npm-shrinkwrap.json @@ -62,6 +62,11 @@ "from": "vscode-extension-telemetry@>=0.0.6 <0.0.7", "resolved": "https://registry.npmjs.org/vscode-extension-telemetry/-/vscode-extension-telemetry-0.0.6.tgz" }, + "vscode-nls": { + "version": "2.0.2", + "from": "vscode-nls@latest", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-2.0.2.tgz" + }, "winreg": { "version": "1.2.3", "from": "winreg@1.2.3", diff --git a/extensions/markdown/package.json b/extensions/markdown/package.json index 593c213056b..713e53b873d 100644 --- a/extensions/markdown/package.json +++ b/extensions/markdown/package.json @@ -69,6 +69,11 @@ "light": "./media/ViewSource.svg", "dark": "./media/ViewSource_inverse.svg" } + }, + { + "command": "markdown.showPreviewSecuritySelector", + "title": "%markdown.showPreviewSecuritySelector.title%", + "category": "Markdown" } ], "menus": { @@ -83,6 +88,10 @@ "when": "resourceScheme == markdown", "command": "markdown.showSource", "group": "navigation" + }, + { + "when": "resourceScheme == markdown", + "command": "markdown.showPreviewSecuritySelector" } ], "explorer/context": [ @@ -183,7 +192,8 @@ "highlight.js": "^9.3.0", "markdown-it": "^8.2.2", "markdown-it-named-headers": "0.0.4", - "vscode-extension-telemetry": "^0.0.6" + "vscode-extension-telemetry": "^0.0.6", + "vscode-nls": "^2.0.2" }, "devDependencies": { "@types/node": "^7.0.4" diff --git a/extensions/markdown/package.nls.json b/extensions/markdown/package.nls.json index 253ab8910ef..c79bb2952a9 100644 --- a/extensions/markdown/package.nls.json +++ b/extensions/markdown/package.nls.json @@ -10,5 +10,6 @@ "markdown.previewFrontMatter.dec": "Sets how YAML front matter should be rendered in the markdown preview. 'hide' removes the front matter. Otherwise, the front matter is treated as markdown content.", "markdown.previewSide.title" : "Open Preview to the Side", "markdown.showSource.title" : "Show Source", - "markdown.styles.dec": "A list of URLs or local paths to CSS style sheets to use from the markdown preview. Relative paths are interpreted relative to the folder open in the explorer. If there is no open folder, they are interpreted relative to the location of the markdown file. All '\\' need to be written as '\\\\'." + "markdown.styles.dec": "A list of URLs or local paths to CSS style sheets to use from the markdown preview. Relative paths are interpreted relative to the folder open in the explorer. If there is no open folder, they are interpreted relative to the location of the markdown file. All '\\' need to be written as '\\\\'.", + "markdown.showPreviewSecuritySelector.title": "Change Markdown Preview Security Settings" } \ No newline at end of file diff --git a/extensions/markdown/src/extension.ts b/extensions/markdown/src/extension.ts index 354d380c7fb..1220ffe9f0c 100644 --- a/extensions/markdown/src/extension.ts +++ b/extensions/markdown/src/extension.ts @@ -11,9 +11,14 @@ import TelemetryReporter from 'vscode-extension-telemetry'; import { MarkdownEngine } from './markdownEngine'; import DocumentLinkProvider from './documentLinkProvider'; import MDDocumentSymbolProvider from './documentSymbolProvider'; -import { MDDocumentContentProvider, getMarkdownUri, isMarkdownFile } from './previewContentProvider'; +import { MDDocumentContentProvider, getMarkdownUri, isMarkdownFile, ContentSecurityPolicyArbiter } from './previewContentProvider'; import { TableOfContentsProvider } from './tableOfContentsProvider'; +import * as nls from 'vscode-nls'; + +const localize = nls.loadMessageBundle(); + + interface IPackageInfo { name: string; version: string; @@ -25,6 +30,37 @@ interface OpenDocumentLinkArgs { fragment: string; } +enum PreviewSecuritySelection { + None, + DisableEnhancedSecurityForWorkspace, + EnableEnhancedSecurityForWorkspace +} + +interface PreviewSecurityPickItem extends vscode.QuickPickItem { + id: PreviewSecuritySelection; +} + +class ExtensionContentSecurityProlicyArbiter implements ContentSecurityPolicyArbiter { + private readonly key = 'trusted_preview_workspace:'; + + constructor( + private globalState: vscode.Memento + ) { } + + public isEnhancedSecurityDisableForWorkspace(): boolean { + return this.globalState.get(this.key + vscode.workspace.rootPath, false); + } + + public addTrustedWorkspace(rootPath: string): Thenable { + return this.globalState.update(this.key + rootPath, true); + } + + public removeTrustedWorkspace(rootPath: string): Thenable { + return this.globalState.update(this.key + rootPath, false); + } + +} + var telemetryReporter: TelemetryReporter | null; export function activate(context: vscode.ExtensionContext) { @@ -34,9 +70,10 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(telemetryReporter); } + const cspArbiter = new ExtensionContentSecurityProlicyArbiter(context.globalState); const engine = new MarkdownEngine(); - const contentProvider = new MDDocumentContentProvider(engine, context); + const contentProvider = new MDDocumentContentProvider(engine, context, cspArbiter); const contentProviderRegistration = vscode.workspace.registerTextDocumentContentProvider('markdown', contentProvider); const symbolsProvider = new MDDocumentSymbolProvider(engine); @@ -64,7 +101,7 @@ export function activate(context: vscode.ExtensionContext) { }); })); - context.subscriptions.push(vscode.commands.registerCommand('_markdown.didClick', (uri, line) => { + context.subscriptions.push(vscode.commands.registerCommand('_markdown.didClick', (uri: string, line) => { const sourceUri = vscode.Uri.parse(decodeURIComponent(uri)); return vscode.workspace.openTextDocument(sourceUri) .then(document => vscode.window.showTextDocument(document)) @@ -100,6 +137,68 @@ export function activate(context: vscode.ExtensionContext) { } })); + context.subscriptions.push(vscode.commands.registerCommand('markdown.showPreviewSecuritySelector', (resource: string | undefined) => { + const workspacePath = vscode.workspace.rootPath || resource; + if (!workspacePath) { + return; + } + + let sourceUri: vscode.Uri | null = null; + if (resource) { + sourceUri = vscode.Uri.parse(decodeURIComponent(resource)); + } + + if (!sourceUri && vscode.window.activeTextEditor) { + const activeDocument = vscode.window.activeTextEditor.document; + if (activeDocument.uri.scheme === 'markdown') { + sourceUri = activeDocument.uri; + } else { + sourceUri = getMarkdownUri(activeDocument.uri); + } + } + + vscode.window.showQuickPick( + [ + { + id: PreviewSecuritySelection.EnableEnhancedSecurityForWorkspace, + label: localize( + 'preview.showPreviewSecuritySelector.disallowScriptsForWorkspaceTitle', + 'Disable script execution in markdown previews for this workspace'), + description: '', + detail: cspArbiter.isEnhancedSecurityDisableForWorkspace() + ? '' + : localize('preview.showPreviewSecuritySelector.currentSelection', 'Current setting') + }, { + id: PreviewSecuritySelection.DisableEnhancedSecurityForWorkspace, + label: localize( + 'preview.showPreviewSecuritySelector.allowScriptsForWorkspaceTitle', + 'Enable script execution in markdown previews for this workspace'), + description: '', + detail: cspArbiter.isEnhancedSecurityDisableForWorkspace() + ? localize('preview.showPreviewSecuritySelector.currentSelection', 'Current setting') + : '' + }, + ], { + placeHolder: localize('preview.showPreviewSecuritySelector.title', 'Change security settings for the Markdown preview'), + }).then(selection => { + if (!workspacePath) { + return false; + } + switch (selection && selection.id) { + case PreviewSecuritySelection.DisableEnhancedSecurityForWorkspace: + return cspArbiter.addTrustedWorkspace(workspacePath).then(() => true); + + case PreviewSecuritySelection.EnableEnhancedSecurityForWorkspace: + return cspArbiter.removeTrustedWorkspace(workspacePath).then(() => true); + } + return false; + }).then(shouldUpdate => { + if (shouldUpdate && sourceUri) { + contentProvider.update(sourceUri); + } + }); + })); + context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(document => { if (isMarkdownFile(document)) { const uri = getMarkdownUri(document.uri); diff --git a/extensions/markdown/src/previewContentProvider.ts b/extensions/markdown/src/previewContentProvider.ts index 6a65997b19f..0c50c8727c0 100644 --- a/extensions/markdown/src/previewContentProvider.ts +++ b/extensions/markdown/src/previewContentProvider.ts @@ -9,6 +9,13 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { MarkdownEngine } from './markdownEngine'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +export interface ContentSecurityPolicyArbiter { + isEnhancedSecurityDisableForWorkspace(): boolean; +} + export function isMarkdownFile(document: vscode.TextDocument) { return document.languageId === 'markdown' && document.uri.scheme !== 'markdown'; // prevent processing of own documents @@ -24,7 +31,8 @@ export class MDDocumentContentProvider implements vscode.TextDocumentContentProv constructor( private engine: MarkdownEngine, - private context: vscode.ExtensionContext + private context: vscode.ExtensionContext, + private cspArbiter: ContentSecurityPolicyArbiter ) { } private getMediaPath(mediaFile: string): string { @@ -60,7 +68,7 @@ export class MDDocumentContentProvider implements vscode.TextDocumentContentProv return vscode.Uri.file(path.join(path.dirname(resource.fsPath), href)).toString(); } - private computeCustomStyleSheetIncludes(uri: vscode.Uri): string { + private computeCustomStyleSheetIncludes(uri: vscode.Uri, _nonce: string): string { const styles = vscode.workspace.getConfiguration('markdown')['styles']; if (styles && Array.isArray(styles) && styles.length > 0) { return styles.map((style) => { @@ -70,13 +78,13 @@ export class MDDocumentContentProvider implements vscode.TextDocumentContentProv return ''; } - private getSettingsOverrideStyles(): string { + private getSettingsOverrideStyles(nonce: string): string { const previewSettings = vscode.workspace.getConfiguration('markdown')['preview']; if (!previewSettings) { return ''; } const { fontFamily, fontSize, lineHeight } = previewSettings; - return `