Skip to content

Commit

Permalink
Introduce sandboxed iframe mode. (ampproject#1042)
Browse files Browse the repository at this point in the history
* Introduce sandbox (iframe) mode for the Worker

* Reduce filesize via IS_AMP

* address the nits

* Add jsdoc, and move iframe contract comment
  • Loading branch information
samouri authored Apr 2, 2021
1 parent 6e9de16 commit bce0b83
Show file tree
Hide file tree
Showing 12 changed files with 354 additions and 5 deletions.
6 changes: 6 additions & 0 deletions config/rollup.main-thread.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const ESModules = [
replace({
values: {
WORKER_DOM_DEBUG: false,
IS_AMP: false,
},
preventAssignment: true,
}),
Expand All @@ -56,6 +57,7 @@ const ESModules = [
replace({
values: {
WORKER_DOM_DEBUG: true,
IS_AMP: false,
},
preventAssignment: true,
}),
Expand All @@ -78,6 +80,7 @@ const ESModules = [
replace({
values: {
WORKER_DOM_DEBUG: false,
IS_AMP: true,
},
preventAssignment: true,
}),
Expand All @@ -101,6 +104,7 @@ const ESModules = [
replace({
values: {
WORKER_DOM_DEBUG: true,
IS_AMP: true,
},
preventAssignment: true,
}),
Expand All @@ -127,6 +131,7 @@ const IIFEModules = [
replace({
values: {
WORKER_DOM_DEBUG: false,
IS_AMP: false,
},
preventAssignment: true,
}),
Expand All @@ -151,6 +156,7 @@ const IIFEModules = [
replace({
values: {
WORKER_DOM_DEBUG: true,
IS_AMP: false,
},
preventAssignment: true,
}),
Expand Down
1 change: 1 addition & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ <h3>Basic</h3>
<li><a href='default-input-listener/'>Default Input Listener</a></li>
<li><a href='call-function/'>Call Function</a></li>
<li><a href='iframe/'>Iframed storage</a></li>
<li><a href='sandboxed/'>Sandboxed Worker (via iframe)</a></li>
<li><a href='scroll-into-view/'>Scroll into view</a></li>
</ul>

Expand Down
33 changes: 33 additions & 0 deletions demo/sandboxed/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>IframeWorker Sandbox</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/dist/amp-debug/main.mjs" type="module"></script>
</head>
<body>
<div src="/hello-world/hello-world.js" id="upgrade-me">
<div class="root">
<button>Insert Hello World!</button>
</div>
</div>
<script type="module">
import { upgradeElement } from '/dist/amp-debug/main.mjs';
upgradeElement(
document.getElementById('upgrade-me'),
'/dist/amp-debug/worker/worker.mjs',
() => {},
undefined,
{ iframeUrl: 'sandbox-iframe.html' }
).then((worker) => {
worker.onmessage = (msg) =>
console.log(`onmessage: ${JSON.stringify(msg)}`);
worker.onmessageerror = (msg) =>
console.error(`msgerror ${JSON.stringify(msg)}`);
worker.onerror = (msg) =>
console.error(`error: ${JSON.stringify(msg)}`);
});
</script>
</body>
</html>
63 changes: 63 additions & 0 deletions demo/sandboxed/sandbox-iframe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<html>
<script>
/**
* See `iframe-worker.ts` for the iframe proxy contract.
*/
let parentOrigin = '*';
const MESSAGE_TYPES = {
ready: 'iframe-ready',
init: 'init-worker',
onmessage: 'onmessage',
onerror: 'onerror',
onmessageerror: 'onmessageerror',
postMessage: 'postMessage',
};
function send(type, message) {
if (type !== MESSAGE_TYPES.ready && parentOrigin === '*') {
throw new Error('Broadcast banned except for iframe-ready message.');
}
parent.postMessage({ type, message }, parentOrigin);
}

function listen(type, handler) {
window.addEventListener('message', (event) => {
if (event.source !== parent) {
return;
}
parentOrigin = event.origin;

if (event.data.type === type) {
handler(event.data);
}
});
}

// Send initialization.
send(MESSAGE_TYPES.ready);

let worker = null;
// Listen for Worker Init.
listen(MESSAGE_TYPES.init, ({ code }) => {
if (worker) {
return;
}
worker = new Worker(URL.createObjectURL(new Blob([code])));

// Proxy messages Worker to parent Window.
worker.onmessage = (e) => send(MESSAGE_TYPES.onmessage, e.data);
worker.onmessageerror = (e) => send(MESSAGE_TYPES.onmessageerror, e.data);
worker.onerror = (e) =>
send(MESSAGE_TYPES.onerror, {
lineno: e.lineno,
colno: e.colno,
message: e.message,
filename: e.filename,
});

// Proxy message from parent Window to Worker.
listen(MESSAGE_TYPES.postMessage, ({ message }) =>
worker.postMessage(message)
);
});
</script>
</html>
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"brotli": "13.5 kB"
},
"./dist/amp-production/main.mjs": {
"brotli": "4 kB"
"brotli": "4.5 kB"
},
"./dist/amp-production/worker/worker.mjs": {
"brotli": "13 kB"
Expand Down
4 changes: 4 additions & 0 deletions src/main-thread/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface InboundWorkerDOMConfiguration {
hydrateFilter?: HydrationFilterPredicate;
// Executor Filter, allow list
executorsAllowed?: Array<number>;
// Extra layer of sandboxing by placing Worker inside of iframe.
sandbox?: { iframeUrl: string };

// ---- Optional Callbacks
// Called when worker consumes the page's initial DOM state.
Expand Down Expand Up @@ -74,6 +76,8 @@ export interface WorkerDOMConfiguration {
sanitizer?: Sanitizer;
// Hydration Filter Predicate
hydrateFilter?: HydrationFilterPredicate;
// Extra layer of sandboxing by placing Worker inside of iframe.
sandbox?: { iframeUrl: string };

// ---- Optional Callbacks
// Called when worker consumes the page's initial DOM state.
Expand Down
2 changes: 1 addition & 1 deletion src/main-thread/exported-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { TransferrableMutationType } from '../transfer/TransferrableMutation';
* An ExportedWorker is returned by the upgradeElement API.
* For the most part, it delegates to the underlying Worker.
*
* It notably removes `postMessage` support and add `callFunction`.
* It notably removes `postMessage` support and adds `callFunction`.
*/
export class ExportedWorker {
workerContext_: WorkerContext;
Expand Down
112 changes: 112 additions & 0 deletions src/main-thread/iframe-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Copyright 2021 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

type MessageFromWorker = {
type: 'onmessage' | 'onerror' | 'onmessageerror';
message: any;
};
export type MessageFromIframe = { type: 'iframe-ready' } | MessageFromWorker;
export type MessageToIframe = { type: 'terminate' } | { type: 'init-worker'; code: string } | { type: 'postMessage'; message: any };

/**
* An almost drop-in replacement for a standard Web Worker, although this one
* within a sandboxed cross-origin iframe for a heightened security boundary.
* For more details on Worker, see: https://developer.mozilla.org/en-US/docs/Web/API/Worker
*
* The iframe used for sandboxing must follow a specific contract. It:
* 1. Must send a ready message to the main-thread.
* 2. Must listen for a message from main-thread with the code to initialize a Worker with.
* 3. Must proxy all messages between the Worker and Parent, including errors.
*/
class IframeWorker {
// Public Worker API
public onerror: (this: IframeWorker, ev: ErrorEvent) => any;
public onmessage: (this: IframeWorker, ev: MessageEvent) => any;
public onmessageerror: (this: IframeWorker, ev: MessageEvent) => any;

// Internal variables.
private iframe: HTMLIFrameElement;

/**
* @param url The URL to initiate the worker from.
* @param iframeUrl The URL of the iframe to use as the worker proxy.
*/
constructor(private url: string | URL, iframeUrl: string) {
this.iframe = window.document.createElement('iframe');
this.iframe.setAttribute('sandbox', 'allow-scripts');
this.iframe.setAttribute('style', 'display:none');
this.iframe.setAttribute('src', iframeUrl);
this.url = url;

this.setupInit();
this.proxyFromWorker();
window.document.body.appendChild(this.iframe);
}

private setupInit() {
const listener = async (event: MessageEvent) => {
if (event.source != this.iframe.contentWindow) {
return;
}

const code = await fetch(this.url.toString()).then((res) => res.text());
if ((event.data as MessageFromIframe).type == 'iframe-ready') {
const msg: MessageToIframe = { type: 'init-worker', code };
this.iframe.contentWindow!.postMessage(msg, '*');
}
window.removeEventListener('message', listener);
};
window.addEventListener('message', listener);
}

private proxyFromWorker() {
window.addEventListener('message', (event: MessageEvent) => {
if (event.source != this.iframe.contentWindow) {
return;
}

const { type, message } = event.data as MessageFromWorker;
if (type == 'onmessage' && this.onmessage) {
this.onmessage({ ...event, data: message });
} else if (type === 'onerror' && this.onerror) {
this.onerror(message);
} else if (type === 'onmessageerror' && this.onmessageerror) {
this.onmessageerror({ ...event, data: message });
}
});
}

/**
* See https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage
* @param message
* @param transferables
*/
postMessage(message: any, transferables?: Array<Transferable>) {
const msg: MessageToIframe = { type: 'postMessage', message };
this.iframe.contentWindow!.postMessage(msg, '*', transferables);
}

/**
* See https://developer.mozilla.org/en-US/docs/Web/API/Worker/terminate
*/
terminate() {
const msg: MessageToIframe = { type: 'terminate' };
this.iframe.contentWindow!.postMessage(msg, '*');
this.iframe.remove();
}
}

export { IframeWorker };
2 changes: 2 additions & 0 deletions src/main-thread/index.amp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function upgradeElement(
domURL: string,
longTask?: LongTaskFunction,
sanitizer?: Sanitizer,
sandbox?: { iframeUrl: string },
): Promise<ExportedWorker | null> {
const authorURL = baseElement.getAttribute('src');
if (authorURL) {
Expand All @@ -54,6 +55,7 @@ export function upgradeElement(
longTask,
hydrateFilter,
sanitizer,
sandbox,
});
}
return Promise.resolve(null);
Expand Down
1 change: 1 addition & 0 deletions src/main-thread/main-thread.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ type RenderableElement = (HTMLElement | SVGElement | Text | Comment) & {
};

declare const WORKER_DOM_DEBUG: boolean;
declare const IS_AMP: boolean;
11 changes: 8 additions & 3 deletions src/main-thread/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import { readableHydrateableRootNode, readableMessageToWorker } from './debuggin
import { NodeContext } from './nodes';
import { TransferrableKeys } from '../transfer/TransferrableKeys';
import { StorageLocation } from '../transfer/TransferrableStorage';
import { IframeWorker } from './iframe-worker';

// TODO: Sanitizer storage init is likely broken, since the code currently
// attempts to stringify a Promise.
export type StorageInit = { storage: Storage | Promise<StorageValue>; errorMsg: null } | { storage: null; errorMsg: string };
export class WorkerContext {
private [TransferrableKeys.worker]: Worker;
private [TransferrableKeys.worker]: Worker | IframeWorker;
private nodeContext: NodeContext;
private config: WorkerDOMConfiguration;

Expand Down Expand Up @@ -80,7 +81,11 @@ export class WorkerContext {
}).call(self);
${authorScript}
//# sourceURL=${encodeURI(config.authorURL)}`;
this[TransferrableKeys.worker] = new Worker(URL.createObjectURL(new Blob([code])));
if (!config.sandbox) {
this[TransferrableKeys.worker] = new Worker(URL.createObjectURL(new Blob([code])));
} else if (IS_AMP) {
this[TransferrableKeys.worker] = new IframeWorker(URL.createObjectURL(new Blob([code])), config.sandbox.iframeUrl);
}
if (WORKER_DOM_DEBUG) {
console.info('debug', 'hydratedNode', readableHydrateableRootNode(baseElement, config, this));
}
Expand All @@ -92,7 +97,7 @@ export class WorkerContext {
/**
* Returns the private worker.
*/
get worker(): Worker {
get worker(): Worker | IframeWorker {
return this[TransferrableKeys.worker];
}

Expand Down
Loading

0 comments on commit bce0b83

Please sign in to comment.