Source code

Revision control

Copy as Markdown

Other Tools

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