Wrap web worker extension host in an iframe to get a different origin

This commit is contained in:
Alex Dima
2020-08-03 14:52:53 +02:00
parent 532c7b1c78
commit f43a17578c
5 changed files with 238 additions and 69 deletions
+3 -1
View File
@@ -254,7 +254,9 @@ async function handleBuiltInExtension(req, res, parsedUrl) {
if (!filePath) {
return serveError(req, res, 400, `Bad request.`);
}
return serveFile(req, res, filePath);
return serveFile(req, res, filePath, {
'Access-Control-Allow-Origin': '*'
});
}
/**
+3 -3
View File
@@ -28,11 +28,11 @@ function getWorker(workerId: string, label: string): Worker | Promise<Worker> {
}
// ESM-comment-begin
export function getWorkerBootstrapUrl(scriptPath: string, label: string): string {
if (/^(http:)|(https:)|(file:)/.test(scriptPath)) {
export function getWorkerBootstrapUrl(scriptPath: string, label: string, forceDataUri: boolean = false): string {
if (forceDataUri || /^(http:)|(https:)|(file:)/.test(scriptPath)) {
const currentUrl = String(window.location);
const currentOrigin = currentUrl.substr(0, currentUrl.length - window.location.hash.length - window.location.search.length - window.location.pathname.length);
if (scriptPath.substring(0, currentOrigin.length) !== currentOrigin) {
if (forceDataUri || scriptPath.substring(0, currentOrigin.length) !== currentOrigin) {
// this is the cross-origin case
// i.e. the webpage is running at a different origin than where the scripts are loaded from
const myPath = 'vs/base/worker/defaultWorkerFactory.js';
@@ -5,6 +5,7 @@
import * as nls from 'vs/nls';
import * as path from 'vs/base/common/path';
import * as platform from 'vs/base/common/platform';
import { originalFSPath, joinPath } from 'vs/base/common/resources';
import { Barrier, timeout } from 'vs/base/common/async';
import { dispose, toDisposable, DisposableStore, Disposable } from 'vs/base/common/lifecycle';
@@ -383,7 +384,14 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme
subscriptions: [],
get extensionUri() { return extensionDescription.extensionLocation; },
get extensionPath() { return extensionDescription.extensionLocation.fsPath; },
asAbsolutePath(relativePath: string) { return path.join(extensionDescription.extensionLocation.fsPath, relativePath); },
asAbsolutePath(relativePath: string) {
if (platform.isWeb) {
// web worker
return URI.joinPath(extensionDescription.extensionLocation, relativePath).toString();
} else {
return path.join(extensionDescription.extensionLocation.fsPath, relativePath);
}
},
get storagePath() { return that._storagePath.workspaceValue(extensionDescription)?.fsPath; },
get globalStoragePath() { return that._storagePath.globalValue(extensionDescription).fsPath; },
get logPath() { return path.join(that._initData.logsLocation.fsPath, extensionDescription.identifier.value); },
@@ -5,7 +5,7 @@
import { getWorkerBootstrapUrl } from 'vs/base/worker/defaultWorkerFactory';
import { Emitter, Event } from 'vs/base/common/event';
import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
import { toDisposable, Disposable } from 'vs/base/common/lifecycle';
import { IMessagePassingProtocol } from 'vs/base/parts/ipc/common/ipc';
import { VSBuffer } from 'vs/base/common/buffer';
import { createMessageOfType, MessageType, isMessageOfType } from 'vs/workbench/services/extensions/common/extensionHostProtocol';
@@ -16,6 +16,7 @@ import { ILabelService } from 'vs/platform/label/common/label';
import { ILogService } from 'vs/platform/log/common/log';
import { IExtensionDescription } from 'vs/platform/extensions/common/extensions';
import * as platform from 'vs/base/common/platform';
import * as dom from 'vs/base/browser/dom';
import { URI } from 'vs/base/common/uri';
import { IExtensionHost, ExtensionHostLogFileName, ExtensionHostKind } from 'vs/workbench/services/extensions/common/extensions';
import { IProductService } from 'vs/platform/product/common/productService';
@@ -24,6 +25,10 @@ import { joinPath } from 'vs/base/common/resources';
import { Registry } from 'vs/platform/registry/common/platform';
import { IOutputChannelRegistry, Extensions } from 'vs/workbench/services/output/common/output';
import { localize } from 'vs/nls';
import { generateUuid } from 'vs/base/common/uuid';
import { canceled, onUnexpectedError } from 'vs/base/common/errors';
const WRAP_IN_IFRAME = true;
export interface IWebWorkerExtensionHostInitData {
readonly autoStart: boolean;
@@ -34,17 +39,17 @@ export interface IWebWorkerExtensionHostDataProvider {
getInitData(): Promise<IWebWorkerExtensionHostInitData>;
}
export class WebWorkerExtensionHost implements IExtensionHost {
export class WebWorkerExtensionHost extends Disposable implements IExtensionHost {
public readonly kind = ExtensionHostKind.LocalWebWorker;
public readonly remoteAuthority = null;
private _toDispose = new DisposableStore();
private _isTerminating: boolean = false;
private _protocol?: IMessagePassingProtocol;
private readonly _onDidExit = this._register(new Emitter<[number, string | null]>());
public readonly onExit: Event<[number, string | null]> = this._onDidExit.event;
private readonly _onDidExit = new Emitter<[number, string | null]>();
readonly onExit: Event<[number, string | null]> = this._onDidExit.event;
private _isTerminating: boolean;
private _protocolPromise: Promise<IMessagePassingProtocol> | null;
private _protocol: IMessagePassingProtocol | null;
private readonly _extensionHostLogsLocation: URI;
private readonly _extensionHostLogFile: URI;
@@ -58,76 +63,218 @@ export class WebWorkerExtensionHost implements IExtensionHost {
@IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService,
@IProductService private readonly _productService: IProductService,
) {
super();
this._isTerminating = false;
this._protocolPromise = null;
this._protocol = null;
this._extensionHostLogsLocation = URI.file(this._environmentService.logsPath).with({ scheme: this._environmentService.logFile.scheme });
this._extensionHostLogFile = joinPath(this._extensionHostLogsLocation, `${ExtensionHostLogFileName}.log`);
}
async start(): Promise<IMessagePassingProtocol> {
if (!this._protocol) {
const emitter = new Emitter<VSBuffer>();
const url = getWorkerBootstrapUrl(require.toUrl('../worker/extensionHostWorkerMain.js'), 'WorkerExtensionHost');
const worker = new Worker(url, { name: 'WorkerExtensionHost' });
worker.onmessage = (event) => {
const { data } = event;
if (!(data instanceof ArrayBuffer)) {
console.warn('UNKNOWN data received', data);
this._onDidExit.fire([77, 'UNKNOWN data received']);
return;
}
emitter.fire(VSBuffer.wrap(new Uint8Array(data, 0, data.byteLength)));
};
worker.onerror = (event) => {
console.error(event.message, event.error);
this._onDidExit.fire([81, event.message || event.error]);
};
// keep for cleanup
this._toDispose.add(emitter);
this._toDispose.add(toDisposable(() => worker.terminate()));
const protocol: IMessagePassingProtocol = {
onMessage: emitter.event,
send: vsbuf => {
const data = vsbuf.buffer.buffer.slice(vsbuf.buffer.byteOffset, vsbuf.buffer.byteOffset + vsbuf.buffer.byteLength);
worker.postMessage(data, [data]);
}
};
// extension host handshake happens below
// (1) <== wait for: Ready
// (2) ==> send: init data
// (3) <== wait for: Initialized
await Event.toPromise(Event.filter(protocol.onMessage, msg => isMessageOfType(msg, MessageType.Ready)));
protocol.send(VSBuffer.fromString(JSON.stringify(await this._createExtHostInitData())));
await Event.toPromise(Event.filter(protocol.onMessage, msg => isMessageOfType(msg, MessageType.Initialized)));
// Register log channel for web worker exthost log
Registry.as<IOutputChannelRegistry>(Extensions.OutputChannels).registerChannel({ id: 'webWorkerExtHostLog', label: localize('name', "Worker Extension Host"), file: this._extensionHostLogFile, log: true });
this._protocol = protocol;
public async start(): Promise<IMessagePassingProtocol> {
if (!this._protocolPromise) {
if (WRAP_IN_IFRAME) {
this._protocolPromise = this._startInsideIframe();
} else {
this._protocolPromise = this._startOutsideIframe();
}
this._protocolPromise.then(protocol => this._protocol = protocol);
}
return this._protocol;
return this._protocolPromise;
}
dispose(): void {
if (!this._protocol) {
this._toDispose.dispose();
private _startInsideIframe(): Promise<IMessagePassingProtocol> {
const emitter = this._register(new Emitter<VSBuffer>());
const iframe = document.createElement('iframe');
iframe.setAttribute('class', 'web-worker-ext-host-iframe');
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.style.display = 'none';
const nonce = generateUuid();
const vscodeWebWorkerExtHostId = generateUuid();
const workerUrl = require.toUrl('../worker/extensionHostWorkerMain.js');
const js = `
(function() {
const workerUrl = "${getWorkerBootstrapUrl(workerUrl, 'WorkerExtensionHost', true)}";
const worker = new Worker(workerUrl, { name: 'WorkerExtensionHost' });
const vscodeWebWorkerExtHostId = '${vscodeWebWorkerExtHostId}';
worker.onmessage = (event) => {
const { data } = event;
if (!(data instanceof ArrayBuffer)) {
console.warn('Unknown data received', data);
window.parent.postMessage({
vscodeWebWorkerExtHostId,
error: {
name: 'Error',
message: 'Unknown data received',
stack: []
}
}, '*');
return;
}
window.parent.postMessage({
vscodeWebWorkerExtHostId,
data: data
}, '*', [data]);
};
worker.onerror = (event) => {
console.error(event.message, event.error);
window.parent.postMessage({
vscodeWebWorkerExtHostId,
error: {
name: event.error.name,
message: event.error.message,
stack: event.error.stack
}
}, '*');
};
window.addEventListener('message', function(event) {
if (event.source !== window.parent) {
return;
}
if (event.data.vscodeWebWorkerExtHostId !== vscodeWebWorkerExtHostId) {
return;
}
worker.postMessage(event.data.data, [event.data.data]);
}, false);
})();
`;
let sourcesOrigin = location.origin;
if (/^(http:)|(https:)|(file:)/.test(workerUrl)) {
sourcesOrigin = new URL(workerUrl).origin;
}
const html = `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}' 'unsafe-eval' ${sourcesOrigin} https://*.gallerycdn.vsassets.io; worker-src data:; connect-src ${sourcesOrigin} https://*.gallerycdn.vsassets.io" />
</head>
<body>
<script nonce="${nonce}">${js}</script>
</body>
</html>`;
const iframeContent = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
iframe.setAttribute('src', iframeContent);
this._register(dom.addDisposableListener(window, 'message', (event) => {
if (event.source !== iframe.contentWindow) {
return;
}
if (event.data.vscodeWebWorkerExtHostId !== vscodeWebWorkerExtHostId) {
return;
}
if (event.data.error) {
const { name, message, stack } = event.data.error;
const err = new Error();
err.message = message;
err.name = name;
err.stack = stack;
onUnexpectedError(err);
this._onDidExit.fire([18, err.message]);
return;
}
const { data } = event.data;
if (!(data instanceof ArrayBuffer)) {
console.warn('UNKNOWN data received', data);
this._onDidExit.fire([77, 'UNKNOWN data received']);
return;
}
emitter.fire(VSBuffer.wrap(new Uint8Array(data, 0, data.byteLength)));
}));
const protocol: IMessagePassingProtocol = {
onMessage: emitter.event,
send: vsbuf => {
const data = vsbuf.buffer.buffer.slice(vsbuf.buffer.byteOffset, vsbuf.buffer.byteOffset + vsbuf.buffer.byteLength);
iframe.contentWindow!.postMessage({
vscodeWebWorkerExtHostId,
data: data
}, '*', [data]);
}
};
document.body.appendChild(iframe);
this._register(toDisposable(() => iframe.remove()));
return this._performHandshake(protocol);
}
private _startOutsideIframe(): Promise<IMessagePassingProtocol> {
const emitter = new Emitter<VSBuffer>();
const url = getWorkerBootstrapUrl(require.toUrl('../worker/extensionHostWorkerMain.js'), 'WorkerExtensionHost');
const worker = new Worker(url, { name: 'WorkerExtensionHost' });
worker.onmessage = (event) => {
const { data } = event;
if (!(data instanceof ArrayBuffer)) {
console.warn('UNKNOWN data received', data);
this._onDidExit.fire([77, 'UNKNOWN data received']);
return;
}
emitter.fire(VSBuffer.wrap(new Uint8Array(data, 0, data.byteLength)));
};
worker.onerror = (event) => {
console.error(event.message, event.error);
this._onDidExit.fire([81, event.message || event.error]);
};
// keep for cleanup
this._register(emitter);
this._register(toDisposable(() => worker.terminate()));
const protocol: IMessagePassingProtocol = {
onMessage: emitter.event,
send: vsbuf => {
const data = vsbuf.buffer.buffer.slice(vsbuf.buffer.byteOffset, vsbuf.buffer.byteOffset + vsbuf.buffer.byteLength);
worker.postMessage(data, [data]);
}
};
return this._performHandshake(protocol);
}
private async _performHandshake(protocol: IMessagePassingProtocol): Promise<IMessagePassingProtocol> {
// extension host handshake happens below
// (1) <== wait for: Ready
// (2) ==> send: init data
// (3) <== wait for: Initialized
await Event.toPromise(Event.filter(protocol.onMessage, msg => isMessageOfType(msg, MessageType.Ready)));
if (this._isTerminating) {
throw canceled();
}
protocol.send(VSBuffer.fromString(JSON.stringify(await this._createExtHostInitData())));
if (this._isTerminating) {
throw canceled();
}
await Event.toPromise(Event.filter(protocol.onMessage, msg => isMessageOfType(msg, MessageType.Initialized)));
if (this._isTerminating) {
throw canceled();
}
// Register log channel for web worker exthost log
Registry.as<IOutputChannelRegistry>(Extensions.OutputChannels).registerChannel({ id: 'webWorkerExtHostLog', label: localize('name', "Worker Extension Host"), file: this._extensionHostLogFile, log: true });
return protocol;
}
public dispose(): void {
if (this._isTerminating) {
return;
}
this._isTerminating = true;
this._protocol.send(createMessageOfType(MessageType.Terminate));
setTimeout(() => this._toDispose.dispose(), 10 * 1000);
if (this._protocol) {
this._protocol.send(createMessageOfType(MessageType.Terminate));
}
super.dispose();
}
getInspectPort(): number | undefined {
@@ -10,6 +10,7 @@ import { isMessageOfType, MessageType, createMessageOfType } from 'vs/workbench/
import { IInitData } from 'vs/workbench/api/common/extHost.protocol';
import { ExtensionHostMain } from 'vs/workbench/services/extensions/common/extensionHostMain';
import { IHostUtils } from 'vs/workbench/api/common/extHostExtensionService';
import * as path from 'vs/base/common/path';
import 'vs/workbench/api/common/extHost.common.services';
import 'vs/workbench/api/worker/extHost.worker.services';
@@ -35,6 +36,17 @@ self.postMessage = () => console.trace(`'postMessage' has been blocked`);
const nativeAddEventLister = addEventListener.bind(self);
self.addEventLister = () => console.trace(`'addEventListener' has been blocked`);
if (location.protocol === 'data:') {
// make sure new Worker(...) always uses data:
const _Worker = Worker;
Worker = <any>function (stringUrl: string | URL, options?: WorkerOptions) {
const js = `importScripts('${stringUrl}');`;
options = options || {};
options.name = options.name || path.basename(stringUrl.toString());
return new _Worker(`data:text/javascript;charset=utf-8,${encodeURIComponent(js)}`, options);
};
}
//#endregion ---
const hostUtil = new class implements IHostUtils {