diff options
Diffstat (limited to 'toolkit/components/extensions/child/ext-userScripts-content.js')
-rw-r--r-- | toolkit/components/extensions/child/ext-userScripts-content.js | 408 |
1 files changed, 408 insertions, 0 deletions
diff --git a/toolkit/components/extensions/child/ext-userScripts-content.js b/toolkit/components/extensions/child/ext-userScripts-content.js new file mode 100644 index 0000000000..ee1a1b7a8f --- /dev/null +++ b/toolkit/components/extensions/child/ext-userScripts-content.js @@ -0,0 +1,408 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +"use strict"; + +var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled"; +var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`; + +ChromeUtils.defineESModuleGetters(this, { + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "userScriptsEnabled", + USERSCRIPT_PREFNAME, + false +); + +var { ExtensionError } = ExtensionUtils; + +const TYPEOF_PRIMITIVES = ["bigint", "boolean", "number", "string", "symbol"]; + +/** + * Represents a user script in the child content process. + * + * This class implements the API object that is passed as a parameter to the + * browser.userScripts.onBeforeScript API Event. + * + * @param {object} params + * @param {ContentScriptContextChild} params.context + * The context which has registered the userScripts.onBeforeScript listener. + * @param {PlainJSONValue} params.metadata + * An opaque user script metadata value (as set in userScripts.register). + * @param {Sandbox} params.scriptSandbox + * The Sandbox object of the userScript. + */ +class UserScript { + constructor({ context, metadata, scriptSandbox }) { + this.context = context; + this.extension = context.extension; + this.apiSandbox = context.cloneScope; + this.metadata = metadata; + this.scriptSandbox = scriptSandbox; + + this.ScriptError = scriptSandbox.Error; + this.ScriptPromise = scriptSandbox.Promise; + } + + /** + * Returns the API object provided to the userScripts.onBeforeScript listeners. + * + * @returns {object} + * The API object with the properties and methods to export + * to the extension code. + */ + api() { + return { + metadata: this.metadata, + defineGlobals: sourceObject => this.defineGlobals(sourceObject), + export: value => this.export(value), + }; + } + + /** + * Define all the properties of a given plain object as lazy getters of the + * userScript global object. + * + * @param {object} sourceObject + * A set of objects and methods to export into the userScript scope as globals. + * + * @throws {context.Error} + * Throws an apiScript error when sourceObject is not a plain object. + */ + defineGlobals(sourceObject) { + let className; + try { + className = ChromeUtils.getClassName(sourceObject, true); + } catch (e) { + // sourceObject is not an object; + } + + if (className !== "Object") { + throw new this.context.Error( + "Invalid sourceObject type, plain object expected." + ); + } + + this.exportLazyGetters(sourceObject, this.scriptSandbox); + } + + /** + * Convert a given value to make it accessible to the userScript code. + * + * - any property value that is already accessible to the userScript code is returned unmodified by + * the lazy getter + * - any apiScript's Function is wrapped using the `wrapFunction` method + * - any apiScript's Object is lazily exported (and the same wrappers are lazily applied to its + * properties). + * + * @param {any} valueToExport + * A value to convert into an object accessible to the userScript. + * + * @param {object} privateOptions + * A set of options used when this method is called internally (not exposed in the + * api object exported to the onBeforeScript listeners). + * @param {Error} privateOptions.Error + * The Error constructor to use to report errors (defaults to the apiScript context's Error + * when missing). + * @param {Error} privateOptions.errorMessage + * A custom error message to report exporting error on values not allowed. + * + * @returns {any} + * The resulting userScript object. + * + * @throws {context.Error | privateOptions.Error} + * Throws an error when the value is not allowed and it can't be exported into an allowed one. + */ + export(valueToExport, privateOptions = {}) { + const ExportError = privateOptions.Error || this.context.Error; + + if (this.canAccess(valueToExport, this.scriptSandbox)) { + // Return the value unmodified if the userScript principal is already allowed + // to access it. + return valueToExport; + } + + let className; + + try { + className = ChromeUtils.getClassName(valueToExport, true); + } catch (e) { + // sourceObject is not an object; + } + + if (className === "Function") { + return this.wrapFunction(valueToExport); + } + + if (className === "Object") { + return this.exportLazyGetters(valueToExport); + } + + if (className === "Array") { + return this.exportArray(valueToExport); + } + + let valueType = className || typeof valueToExport; + throw new ExportError( + privateOptions.errorMessage || + `${valueType} cannot be exported to the userScript` + ); + } + + /** + * Export all the elements of the `srcArray` into a newly created userScript array. + * + * @param {Array} srcArray + * The apiScript array to export to the userScript code. + * + * @returns {Array} + * The resulting userScript array. + * + * @throws {UserScriptError} + * Throws an error when the array can't be exported successfully. + */ + exportArray(srcArray) { + const destArray = Cu.cloneInto([], this.scriptSandbox); + + for (let [idx, value] of this.shallowCloneEntries(srcArray)) { + destArray[idx] = this.export(value, { + errorMessage: `Error accessing disallowed element at index "${idx}"`, + Error: this.UserScriptError, + }); + } + + return destArray; + } + + /** + * Export all the properties of the `src` plain object as lazy getters on the `dest` object, + * or in a newly created userScript object if `dest` is `undefined`. + * + * @param {object} src + * A set of properties to define on a `dest` object as lazy getters. + * @param {object} [dest] + * An optional `dest` object (a new userScript object is created by default when not specified). + * + * @returns {object} + * The resulting userScript object. + */ + exportLazyGetters(src, dest = undefined) { + dest = dest || Cu.createObjectIn(this.scriptSandbox); + + for (let [key, value] of this.shallowCloneEntries(src)) { + Schemas.exportLazyGetter(dest, key, () => { + return this.export(value, { + // Lazy properties will raise an error for properties with not allowed + // values to the userScript scope, and so we have to raise an userScript + // Error here. + Error: this.ScriptError, + errorMessage: `Error accessing disallowed property "${key}"`, + }); + }); + } + + return dest; + } + + /** + * Export and wrap an apiScript function to provide the following behaviors: + * - errors throws from an exported function are checked by `handleAPIScriptError` + * - returned apiScript's Promises (not accessible to the userScript) are converted into a + * userScript's Promise + * - check if the returned or resolved value is accessible to the userScript code + * (and raise a userScript error if it is not) + * + * @param {Function} fn + * The apiScript function to wrap + * + * @returns {object} + * The resulting userScript function. + */ + wrapFunction(fn) { + return Cu.exportFunction((...args) => { + let res; + try { + // Checks that all the elements in the `...args` array are allowed to be + // received from the apiScript. + for (let arg of args) { + if (!this.canAccess(arg, this.apiSandbox)) { + throw new this.ScriptError( + `Parameter not accessible to the userScript API` + ); + } + } + + res = fn(...args); + } catch (err) { + this.handleAPIScriptError(err); + } + + // Prevent execution of proxy traps while checking if the return value is a Promise. + if (!Cu.isProxy(res) && res instanceof this.context.Promise) { + return this.ScriptPromise.resolve().then(async () => { + let value; + + try { + value = await res; + } catch (err) { + this.handleAPIScriptError(err); + } + + return this.ensureAccessible(value); + }); + } + + return this.ensureAccessible(res); + }, this.scriptSandbox); + } + + /** + * Shallow clone the source object and iterate over its Object properties (or Array elements), + * which allow us to safely iterate over all its properties (including callable objects that + * would be hidden by the xrays vision, but excluding any property that could be tricky, e.g. + * getters). + * + * @param {object | Array} obj + * The Object or Array object to shallow clone and iterate over. + */ + *shallowCloneEntries(obj) { + const clonedObj = ChromeUtils.shallowClone(obj); + + for (let entry of Object.entries(clonedObj)) { + yield entry; + } + } + + /** + * Check if the given value is accessible to the targetScope. + * + * @param {any} val + * The value to check. + * @param {Sandbox} targetScope + * The targetScope that should be able to access the value. + * + * @returns {boolean} + */ + canAccess(val, targetScope) { + if (val == null || TYPEOF_PRIMITIVES.includes(typeof val)) { + return true; + } + + // Disallow objects that are coming from principals that are not + // subsumed by the targetScope's principal. + try { + const targetPrincipal = Cu.getObjectPrincipal(targetScope); + if (!targetPrincipal.subsumes(Cu.getObjectPrincipal(val))) { + return false; + } + } catch (err) { + Cu.reportError(err); + return false; + } + + return true; + } + + /** + * Check if the value returned (or resolved) from an apiScript method is accessible + * to the userScript code, and throw a userScript Error if it is not allowed. + * + * @param {any} res + * The value to return/resolve. + * + * @returns {any} + * The exported value. + * + * @throws {Error} + * Throws a userScript error when the value is not accessible to the userScript scope. + */ + ensureAccessible(res) { + if (this.canAccess(res, this.scriptSandbox)) { + return res; + } + + throw new this.ScriptError("Return value not accessible to the userScript"); + } + + /** + * Handle the error raised (and rejected promise returned) from apiScript functions exported to the + * userScript. + * + * @param {any} err + * The value to return/resolve. + * + * @throws {any} + * This method is expected to throw: + * - any value that is already accessible to the userScript code is forwarded unmodified + * - any value that is not accessible to the userScript code is logged in the console + * (to make it easier to investigate the underlying issue) and converted into a + * userScript Error (with the generic "An unexpected apiScript error occurred" error + * message accessible to the userScript) + */ + handleAPIScriptError(err) { + if (this.canAccess(err, this.scriptSandbox)) { + throw err; + } + + // Log the actual error on the console and raise a generic userScript Error + // on error objects that can't be accessed by the UserScript principal. + try { + const debugName = this.extension.policy.debugName; + Cu.reportError( + `An unexpected apiScript error occurred for '${debugName}': ${err} :: ${err.stack}` + ); + } catch (e) {} + + throw new this.ScriptError(`An unexpected apiScript error occurred`); + } +} + +this.userScriptsContent = class extends ExtensionAPI { + getAPI(context) { + return { + userScripts: { + onBeforeScript: new EventManager({ + context, + name: "userScripts.onBeforeScript", + register: fire => { + if (!userScriptsEnabled) { + throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG); + } + + let handler = (event, metadata, scriptSandbox, eventResult) => { + const us = new UserScript({ + context, + metadata, + scriptSandbox, + }); + + const apiObj = Cu.cloneInto(us.api(), context.cloneScope, { + cloneFunctions: true, + }); + + Object.defineProperty(apiObj, "global", { + value: scriptSandbox, + enumerable: true, + configurable: true, + writable: true, + }); + + fire.raw(apiObj); + }; + + context.userScriptsEvents.on("on-before-script", handler); + return () => { + context.userScriptsEvents.off("on-before-script", handler); + }; + }, + }).api(), + }, + }; + } +}; |