From bd7c397240283879d3eb62a5a284449f27ef6dcc Mon Sep 17 00:00:00 2001 From: Niklas Gollenstede Date: Tue, 15 May 2018 23:01:55 -0500 Subject: [PATCH] rewrite Sandbox and Evaluator as classes extending Port (2.5h) --- .gitignore | 1 + background/evaluator.js | 82 ---------------- background/fallback.js | 4 +- background/index.js | 7 +- background/loaders/wikipedia.js | 8 +- background/utils.js | 21 ++++- common/evaluator.js | 91 ++++++++++++++++++ common/sandbox.js | 159 ++++++++++++++++++-------------- content/index.js | 6 +- content/overlay.js | 4 +- content/panel.html | 2 +- test/plugin/background.js | 4 +- 12 files changed, 220 insertions(+), 169 deletions(-) delete mode 100644 background/evaluator.js create mode 100644 common/evaluator.js diff --git a/.gitignore b/.gitignore index ebb8286..ec2af6f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ npm-debug.log /manifest.json /WikiPeek.html /WikiPeek +/view.html diff --git a/background/evaluator.js b/background/evaluator.js deleted file mode 100644 index f41aada..0000000 --- a/background/evaluator.js +++ /dev/null @@ -1,82 +0,0 @@ -(function(global) { 'use strict'; define(async ({ // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - 'common/sandbox': makeSandbox, -}) => { - -const Self = new WeakMap; -class Evaluator { - constructor() { - return EvaluatorInit.apply(this, arguments); - } - - async eval(code) { - return Self.get(this).request('eval', code); - } - - newFunction(...args) { - const sandbox = Self.get(this), _this = this; - const id = Math.random().toString(32).slice(2); - const stub = async function() { - if (!Self.has(_this)) { throw new TypeError(`Dead remote function called`); } - return sandbox.request('call', id, ...arguments); - }; - Object.defineProperty(stub, 'ready', { value: sandbox.request('create', id, ...args).then(length => { - Object.defineProperty(stub, 'length', { value: length, }); return stub; - }), }); - Object.defineProperty(stub, 'destroy', { value() { Self.has(this) && sandbox.post('destroy', id); }, }); - return stub; - } - - destroy() { - const sandbox = Self.get(this); if (!sandbox) { return; } Self.delete(this); - sandbox.post('destroy'); - sandbox.frame.remove(); - sandbox.destroy(); - } -} - -async function EvaluatorInit() { - Self.set(this, (await makeSandbox(port => { - const FunctionCtor = (x=>x).constructor; - const globEval = eval; - const functions = { }; - port.addHandlers({ - eval(code) { - return globEval(code); - }, - create(id, ...args) { - const func = functions[id] = new FunctionCtor(...args); - return func.length; - }, - call(id, ...args) { - const func = functions[id]; - if (!func) { throw new TypeError(`Dead remote function called`); } - return FunctionCtor.prototype.apply.call(func, func, args); - }, - destroy(id) { - delete functions[id]; - }, - }); - { - const frozen = new Set; - const freeze = object => { - if ((typeof object !== 'object' && typeof object !== 'function') || object === null || frozen.has(object)) { return; } - frozen.add(object); - Object.getOwnPropertyNames(object).forEach(key => { try { freeze(object[key]); } catch (_) { } }); - Object.getOwnPropertySymbols(object).forEach(key => { try { freeze(object[key]); } catch (_) { } }); - // try { freeze(Object.getPrototypeOf(object)); } catch (_) { } - }; - [ 'Object', 'Array', 'Function', 'Math', 'Error', 'TypeError', 'String', 'Number', 'Boolean', 'Symbol', 'RegExp', 'Promise', ] - .forEach(prop => { - Object.defineProperty(window, prop, { writable: false, configurable: false, }); - freeze(window[prop]); - }); - frozen.forEach(Object.freeze); - frozen.clear(); - } - }))); - return this; -} - -return new Evaluator; - -}); })(this); diff --git a/background/fallback.js b/background/fallback.js index 3576559..25e55a9 100644 --- a/background/fallback.js +++ b/background/fallback.js @@ -4,7 +4,7 @@ 'node_modules/web-ext-utils/loader/views': Views, 'node_modules/web-ext-utils/utils/': { reportError, }, 'common/options': options, - 'common/sandbox': makeSandbox, + 'common/sandbox': Sandbox, 'content/panel.js': js, 'fetch!content/panel.css': css, 'fetch!content/panel.html': html, @@ -49,7 +49,7 @@ const methods = { view.addEventListener('unload', () => { view && tab.port && tab.port.destroy(); tab = tab.port = null; }); view.document.title = `Fallback - Wikipedia Peek`; - const port = tab.port = (await makeSandbox(js, { + const port = tab.port = (await new Sandbox(js, { html, srcUrl: require.toUrl('content/panel.js'), host: view.document.body, // needs to reside in the view, otherwise firefox won't give the elements any dimensions })); diff --git a/background/index.js b/background/index.js index 8e2493f..2b0dea3 100644 --- a/background/index.js +++ b/background/index.js @@ -1,7 +1,7 @@ (function(global) { 'use strict'; define(async ({ // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - 'node_modules/web-ext-utils/browser/': { manifest, browserAction, Tabs, runtime, }, + 'node_modules/web-ext-utils/browser/': { manifest, browserAction, Tabs, }, 'node_modules/web-ext-utils/browser/messages': Messages, - 'node_modules/web-ext-utils/browser/version': { gecko, fennec, }, + 'node_modules/web-ext-utils/browser/version': { gecko, }, 'node_modules/web-ext-utils/loader/': { ContentScript, detachFormTab, }, 'node_modules/web-ext-utils/loader/views': Views, 'node_modules/web-ext-utils/update/': updated, @@ -10,14 +10,13 @@ 'common/options': options, Fallback, // loading this on demand is to slow in fennec Loader, - remote, + remote: _, // the remote plugin handler just needs to be loaded early require, }) => { let debug; options.debug.whenChange(([ value, ]) => { debug = value; require('node_modules/web-ext-utils/loader/').debug = debug >= 2; }); debug && console.info(manifest.name, 'loaded, updated', updated); -void remote; // the remote plugin handler just needs to be loaded early // Messages Messages.addHandler(function getPreview() { return Loader.getPreview(this, ...arguments); }); // eslint-disable-line no-invalid-this diff --git a/background/loaders/wikipedia.js b/background/loaders/wikipedia.js index d013515..4ac6364 100644 --- a/background/loaders/wikipedia.js +++ b/background/loaders/wikipedia.js @@ -15,8 +15,9 @@ const options = (await register({ priority: 2, includes: [ '*://*.wikipedia.org/wiki/*', '*://*.mediawiki.org/wiki/*', - String.raw`^https?://.*\.wiki[^\.\/]*?\.org/wiki/.*$`, + String.raw`^https?://([\w-]+\.)*wiki[\w.-]*\.org/wiki/.*$`, '*://*.gamepedia.com/*', + // String.raw`^https?:\/\/([\w-]+\.)*?(wiki[\w-]+|[\w-]+pedia)(\.[\w-]+)*\/wiki\/.*$`, // ], options: { getApiPath: { @@ -92,10 +93,7 @@ function getApiPath(url) { if ((/(?:^|\.)(?:wiki[^\.]*?|mediawiki)\.org$/).test(url.hostname)) { return 'https://'+ url.host +'/w/api.php'; // always use https } - if ((/(?:^|\.)gamepedia\.com$/).test(url.hostname)) { - return 'https://'+ url.host +'/api.php'; // always use https - } - return null; + return 'https://'+ url.host +'/api.php'; // always use https } function getArticleName(url) { diff --git a/background/utils.js b/background/utils.js index 302eacb..9043207 100644 --- a/background/utils.js +++ b/background/utils.js @@ -115,10 +115,29 @@ function image({ src, img, title, description, base, dpi, }) { return ( function setFunctionOnChange(loader, options, func, name = func.name) { + async function getEvaluator() { + if (evaluator) { return evaluator; } + return (evaluator = (await new (await require.async('../common/evaluator'))({ init: function() { + const frozen = new Set; + const freeze = object => { + if ((typeof object !== 'object' && typeof object !== 'function') || object === null || frozen.has(object)) { return; } + frozen.add(object); + Object.getOwnPropertyNames(object).forEach(key => { try { freeze(object[key]); } catch (_) { } }); + Object.getOwnPropertySymbols(object).forEach(key => { try { freeze(object[key]); } catch (_) { } }); + // try { freeze(Object.getPrototypeOf(object)); } catch (_) { } + }; + [ 'Object', 'Array', 'Function', 'Math', 'Error', 'TypeError', 'String', 'Number', 'Boolean', 'Symbol', 'RegExp', 'Promise', ] + .forEach(prop => { + Object.defineProperty(window, prop, { writable: false, configurable: false, }); + freeze(window[prop]); + }); + frozen.forEach(Object.freeze); + }, }))); + } let evaluator; options[name].whenChange(async ([ value, ]) => { try { loader[name].destroy && loader[name].destroy(); loader[name] = options[name].values.isSet - ? (await require.async('./evaluator')).newFunction('url', value) : func; + ? (await getEvaluator()).newFunction('url', value) : func; return loader[name].ready; } catch (error) { reportError(`Could not compile "${ name }" for "${ loader.name }"`, error); throw error; } }); } diff --git a/common/evaluator.js b/common/evaluator.js new file mode 100644 index 0000000..f703445 --- /dev/null +++ b/common/evaluator.js @@ -0,0 +1,91 @@ +(function(global) { 'use strict'; define(async ({ // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + 'common/sandbox': Sandbox, +}) => { + +/** + * Wrapper around a sandboxed iframe that can safely evaluate user input as JavaScript code. + */ +class Evaluator extends Sandbox { + + /** + * @param {function?} .init Optional function that does the initial setup inside the sandbox. + * Gets re-compiled and called with one end of the Port connection as the only argument. + * @param {properties} ...options Other options forwarded to `makeSandbox`. + */ + /* async */ constructor({ init, ...options } = { }) { return (async () => { let self; { + // TODO: could append `options.srcUrl` to `eval`ed scripts + self = (await super(setup, options)); + (await self.post('init', typeof init === 'function' ? init +'' : null)); + } return self; })(); } + + /** + * `eval()`s code in the global scope and returns a JSON clone of the (resolved) value. + * @param {string} code Code to execute. + * @return {JSON} Resolved value returned by the evaluated code as a JSON clone. + */ + async eval(code) { + return this.request(':eval', code); + } + + /** + * Creates a `new AsyncFunction` in the sandbox and returns a function stub that calls it. + * @param {...[string]} args Arguments forwarded to the function constructor. + * @return {async function} Stub function that calls the function with JSON cloned arguments + * and asynchronously returns a JSON clone of its return value. + * @method destroy Deletes the remote function. + * @property ready Promise that needs to resolve before the `.length` has a useful value. + */ + newFunction(...args) { + const self = this; + const id = Math.random().toString(32).slice(2); + const stub = async function() { return self.request(':F()', id, ...arguments); }; + Object.defineProperty(stub, 'ready', { value: self.request(':new F', id, ...args).then(length => { + Object.defineProperty(stub, 'length', { value: length, }); return stub; + }), }); + Object.defineProperty(stub, 'destroy', { value() { try { self.post(':~F', id); } catch (_) { } }, }); + return stub; + } + +} + +//// start implementation + +function setup(port) { + const AsyncFunction = (async x=>x).constructor; + const globEval = eval; // eval in global scope + const functions = { }; + port.addHandlers({ + async init(code) { + try { code && (await globEval(code)(port)); } + finally { port.removeHandler('init'); } + }, + async ':eval'(code) { + return globEval(code); + }, + ':new F'(id, ...args) { + const func = functions[id] = new AsyncFunction(...args); + return func.length; + }, + ':F()'(id, ...args) { + const func = functions[id]; + if (!func) { throw new TypeError(`Dead remote function called`); } + return func(...args); + }, + ':~F'(id) { + delete functions[id]; + }, + }); +} + +return Evaluator; + +/* +E = await new (await require.async('background/evaluator'))({ init: port => { + window.openTab = url => port.request('browser.tabs.create', { url, }); +}, }); +E.addHandlers('browser.tabs.', Browser.tabs); +F = E.newFunction('url', 'openTab(url)'); await F("https://example.com"); F.destroy(); +*/ + + +}); })(this); diff --git a/common/sandbox.js b/common/sandbox.js index e16b190..682f754 100644 --- a/common/sandbox.js +++ b/common/sandbox.js @@ -2,95 +2,120 @@ 'node_modules/web-ext-utils/lib/multiport/': Port, 'node_modules/web-ext-utils/browser/': { rootUrl, inContent, }, 'node_modules/web-ext-utils/browser/version': { gecko, }, - require, + options, require, }) => { /* global URL, Blob, btoa, */ /** - * Creates a sandboxed iframe and returns a es6lib/Port connected to it. - * @param {function} init Function that does the initial setup inside the sandbox. - * Gets re-compiled and called with one end of the Port connection as the only argument. - * @param {object} options Optional Object with all optional properties to customize the sandbox. - * @param {string} options.html Custom HTML for the frame, must not include the closing `` tag. - * @param {Boolean} options.strict Whether to include a global 'use strict' directive. Defaults to true. - * @param {string} options.srcUrl Logical URL of `init` for debugging and stack traces. - * @param {String} options.sandbox Custom value for the `sandbox` attribute of the iframe. Defaults to `"allow-scripts"`. - * @param {Element} options.host Element to attach the frame to. Defaults to `document.head`. - * @return {Port} es6lib/Port instance with `.frame` as additional property. + * A Port connected to a sandboxed iframe. + * @property {Element} frame The iframe element that was added to the DOM. */ -async function makeSandbox(init, { - html = ``, // without , - strict = true, - srcUrl = rootUrl +'eval', - sandbox = 'allow-scripts', - host = global.document.head, -} = { }) { - - const script = `${ strict ? "'use strict';" : '' } \0 - document.currentScript.remove(); \0 - (Port => window.onmessage = ({ ports: [ port1, ], }) => { \0 - port1.postMessage([ 'loaded', 0, [ ], ]); \0 - const port = new Port(port1, Port.MessagePort); \0 - window.onmessage = null; port1 = null; \0 - (${ init })(port); \0 - })((function(global) { 'use strict'; return (${ require.cache['node_modules/web-ext-utils/lib/multiport/index'].factory })(); })(window)); - //# sourceURL=${ srcUrl }\n`.replace(/ \0[\r\n]\s*/g, ' '); +class Sandbox extends Port { + + /** + * Creates a sandboxed iframe and returns a es6lib/Port connected to it. + * @param {function} init Function that does the initial setup inside the sandbox. + * Gets re-compiled and called with one end of the Port connection as the only argument. + * @param {string?} .html Custom HTML for the frame, must not include the closing `` tag. + * @param {string?} .srcUrl Logical URL of `init` for debugging and stack traces. + * @param {string?} .sandbox Custom value for the `sandbox` attribute of the iframe. Defaults to `"allow-scripts"`. + * @param {Element?} .host Element to attach the frame to. Defaults to `document.head`. + */ + /* async */ constructor(init, { + html = ``, + srcUrl = rootUrl +'eval', + sandbox = 'allow-scripts', + host = global.document.head, + timeout, + } = { }) { return (async () => { { + const frame = createFrame(init, html, srcUrl, sandbox, host); try { + + (await new Promise((load, error) => { + frame.onload = load; frame.onerror = error; + host.appendChild(frame); + gecko && !debug && URL.revokeObjectURL(frame.src); // revoke as soon as possible (but not in chrome, where that would be too early ...) + // if the page listens for 'DOMNodeInserted' (deprecated) and then does a synchronous XHR (deprecated) it can actually read the contents of the Blob. + // the only way this could be prevented would be to listen for 'DOMNodeInserted' earlier and cancel the event + })); + !gecko && !debug && URL.revokeObjectURL(frame.src); + + frame.onload = frame.onerror = null; + + const { port1, port2, } = new host.ownerDocument.defaultView.MessageChannel; + frame.contentWindow.postMessage(null, '*', [ port1, ]); // only the frame content can receive this + super(port2, Port.MessagePort); this.frame = frame; + + (await new Promise((loaded, failed) => { + const done = setIdleTimeout( + () => failed(new Error('Failed to create Sandbox')), + timeout || (inContent ? (gecko ? 250 : 125) : 1000), + ); + this.addHandler('loaded', () => { + this.removeHandler('loaded'); + done(); loaded(); + }); + })); + + } catch (error) { frame.remove(); throw error; } + } return this; })(); } + + destroy() { + this.frame && this.frame.remove(); this.frame = null; + super.destroy(); + } +} + +//// start implementation + + +let debug; options.debug.whenChange(([ value, ]) => { debug = value; }); + +const PortCode = `(function(global) { 'use strict'; return (${ + require.cache['node_modules/web-ext-utils/lib/multiport/index'].factory +})(); })(window)`; + +function createFrame(init, html, srcUrl, sandbox, host) { + let script = [ + `document.currentScript.remove();`, + `(Port => window.onmessage = ({ ports: [ port1, ], }) => {`, + `port1.postMessage([ 'loaded', 0, [ ], ]);`, + `const port = new Port(port1, Port.MessagePort);`, + `window.onmessage = null; port1 = null;`, + `(${ init })(port);`, + `})(${ PortCode });`, + `//# sourceURL=${ srcUrl }\n`, + ].join(' '); if (!inContent) { // Firefox doesn't allow inline scripts in the extension pages, // so the code inside the script itself is allowed by 'sha256-QMSw9XSc08mdsgM/uQhEe2bXMSqOw4JvoBdpHZG21ps=', the eval() needs 'unsafe-eval' - html += ``; + script = ``; } else { - html += ``; + script = ``; } + html = html.replace(rHtmlEnd, script + ''); const url = URL.createObjectURL(new Blob([ html, ], { type: 'text/html', })); const frame = host.ownerDocument.createElement('iframe'); { frame.sandbox = sandbox; frame.src = url; frame.style.display = 'none'; - } - - return new Promise((resolve, reject) => { - frame.onload = () => resolve(onload()); frame.onerror = reject; - host.appendChild(frame); - URL.revokeObjectURL(url); - // if the page listens for 'DOMNodeInserted' (deprecated) and then does a synchronous XHR (deprecated) it can actually read the contents of the Blob. - // the only way this could be prevented would be to listen for 'DOMNodeInserted' earlier and cancel the event - async function onload() { - frame.onload = frame.onerror = null; - - const { port1, port2, } = new host.ownerDocument.defaultView.MessageChannel; - frame.contentWindow.postMessage(null, '*', [ port1, ]); - // nobody but the frame content itself can listen to this - const port = new Port(port2, Port.MessagePort); - - return new Promise((resolve, reject) => { - let done = false; waitIdleTime(inContent ? (gecko ? 250 : 125) : 1000).then(() => !done && reject(new Error('Failed to create Sandbox'))); - port.addHandler(function loaded() { - port.removeHandler('loaded'); - resolve(Object.assign(port, { frame, })); - done = true; - }); - }); - } - }).catch(error => { frame.remove(); throw error; }); + } return frame; } -return makeSandbox; +let rHtmlEnd; try { rHtmlEnd = new RegExp(String.raw`(?).*)<\/html>|$`); } +catch (_) { rHtmlEnd = (/<\/html>|$/); } // lookbehind not supported yet + +return Sandbox; /* global performance, requestIdleCallback, setTimeout, */ -function waitIdleTime(time) { return new Promise(done => { - const start = performance.now(); - if (typeof requestIdleCallback !== 'function') { - return void setTimeout(function loop() { - if ((time -= 5) <= 0) { done(performance.now() - start); } - else { setTimeout(loop, 5); } - }, 5); - } +function setIdleTimeout(callback, time) { + const start = performance.now(); let canceled = false; requestIdleCallback(function loop(idle) { + if (canceled) { return; } const left = Math.max(5, idle.timeRemaining()); time -= left; - if (time <= 0) { done(performance.now() - start); } + if (time <= 0) { callback(performance.now() - start); } else { setTimeout(() => requestIdleCallback(loop), left + 1); } }); -}); } + return function cancel() { canceled = true; }; +} }); })(this); diff --git a/content/index.js b/content/index.js index 9087189..6c8093b 100644 --- a/content/index.js +++ b/content/index.js @@ -19,7 +19,7 @@ const { let loading = null; // the link that is currently being loaded for let overlay = null; /* require.async('./overlay') */ -debug && console.debug('content options', module.config()); +debug && console.info('content options', module.config()); /// returns whether or not the screen has recently been touched and touch friendly behavior should apply const inTouchMode = typeof touchMode === 'boolean' ? () => touchMode @@ -47,7 +47,7 @@ const preventClick = (timeout => () => { */ async function showForElement(link, active) { loading = link; let canceled = false; const cancel = _ => { - debug && console.debug('cancel for', _, link); + debug && console.info('cancel for', _, link); loading = null; canceled = true; overlay && overlay.cancel(link); link.removeEventListener('mouseleave', cancel); @@ -61,7 +61,7 @@ async function showForElement(link, active) { !active && (await sleep(showDelay)); if (canceled) { return; } - debug && console.debug('loading for', link); + debug && console.info('loading for', link); // start loading const getPreview = request('getPreview', link.href); let gotPreview = false; getPreview.then(() => (gotPreview = true)); diff --git a/content/overlay.js b/content/overlay.js index c01cab9..f863076 100644 --- a/content/overlay.js +++ b/content/overlay.js @@ -1,7 +1,7 @@ (function(global) { 'use strict'; define(async ({ // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 'node_modules/web-ext-utils/loader/content': { onUnload, }, 'common/options': options, - 'common/sandbox': makeSandbox, + 'common/sandbox': Sandbox, 'fetch!./panel.css:css': css, 'fetch!./panel.html': html, './panel.js': js, @@ -12,7 +12,7 @@ const HOVER_HIDE_DELAY = 230; // ms const style = options.style.children; let target = null, state = 'hidden'; -const port = (await makeSandbox(js, { +const port = (await new Sandbox(js, { html: html.replace(/\${\s*css\s*}/, css), srcUrl: require.toUrl('./panel.js'), host: document.scrollingElement, diff --git a/content/panel.html b/content/panel.html index 416a49f..34efa7c 100644 --- a/content/panel.html +++ b/content/panel.html @@ -9,4 +9,4 @@
- + diff --git a/test/plugin/background.js b/test/plugin/background.js index b86c33a..640afeb 100644 --- a/test/plugin/background.js +++ b/test/plugin/background.js @@ -32,10 +32,10 @@ const plugin = (await Loader.register({ Current message is "${ plugin.options.values.message[0] }"`; }, })); -console.log('plugin connected'); +console.info('plugin connected'); plugin.onDisconnect.addListener(() => { - console.log('plugin disconnected'); + console.info('plugin disconnected'); main(Loader); });