/* -*- 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) => { 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(), }, }; } };