diff options
Diffstat (limited to 'toolkit/components/extensions/child')
-rw-r--r-- | toolkit/components/extensions/child/.eslintrc.js | 11 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-backgroundPage.js | 40 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-contentScripts.js | 76 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-declarativeNetRequest.js | 35 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-extension.js | 78 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-identity.js | 84 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-runtime.js | 143 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-scripting.js | 49 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-storage.js | 368 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-test.js | 371 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-toolkit.js | 84 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-userScripts-content.js | 408 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-userScripts.js | 192 | ||||
-rw-r--r-- | toolkit/components/extensions/child/ext-webRequest.js | 119 |
14 files changed, 2058 insertions, 0 deletions
diff --git a/toolkit/components/extensions/child/.eslintrc.js b/toolkit/components/extensions/child/.eslintrc.js new file mode 100644 index 0000000000..01f6e45d35 --- /dev/null +++ b/toolkit/components/extensions/child/.eslintrc.js @@ -0,0 +1,11 @@ +/* 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"; + +module.exports = { + globals: { + EventManager: true, + }, +}; diff --git a/toolkit/components/extensions/child/ext-backgroundPage.js b/toolkit/components/extensions/child/ext-backgroundPage.js new file mode 100644 index 0000000000..ef5b3dd339 --- /dev/null +++ b/toolkit/components/extensions/child/ext-backgroundPage.js @@ -0,0 +1,40 @@ +/* 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"; + +this.backgroundPage = class extends ExtensionAPI { + getAPI(context) { + function getBackgroundPage() { + for (let view of context.extension.views) { + if ( + // To find the (top-level) background context, this logic relies on + // the order of views, implied by the fact that the top-level context + // is created before child contexts. If this assumption ever becomes + // invalid, add a check for view.isBackgroundContext. + view.viewType == "background" && + context.principal.subsumes(view.principal) + ) { + return view.contentWindow; + } + } + return null; + } + return { + extension: { + getBackgroundPage, + }, + + runtime: { + getBackgroundPage() { + return context.childManager + .callParentAsyncFunction("runtime.internalWakeupBackground", []) + .then(() => { + return context.cloneScope.Promise.resolve(getBackgroundPage()); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-contentScripts.js b/toolkit/components/extensions/child/ext-contentScripts.js new file mode 100644 index 0000000000..338374cde6 --- /dev/null +++ b/toolkit/components/extensions/child/ext-contentScripts.js @@ -0,0 +1,76 @@ +/* -*- 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 { ExtensionError } = ExtensionUtils; + +/** + * Represents (in the child extension process) a content script registered + * programmatically (instead of being included in the addon manifest). + * + * @param {ExtensionPageContextChild} context + * The extension context which has registered the content script. + * @param {string} scriptId + * An unique id that represents the registered content script + * (generated and used internally to identify it across the different processes). + */ +class ContentScriptChild { + constructor(context, scriptId) { + this.context = context; + this.scriptId = scriptId; + this.unregistered = false; + } + + async unregister() { + if (this.unregistered) { + throw new ExtensionError("Content script already unregistered"); + } + + this.unregistered = true; + + await this.context.childManager.callParentAsyncFunction( + "contentScripts.unregister", + [this.scriptId] + ); + + this.context = null; + } + + api() { + const { context } = this; + + // TODO(rpl): allow to read the options related to the registered content script? + return { + unregister: () => { + return context.wrapPromise(this.unregister()); + }, + }; + } +} + +this.contentScripts = class extends ExtensionAPI { + getAPI(context) { + return { + contentScripts: { + register(options) { + return context.cloneScope.Promise.resolve().then(async () => { + const scriptId = await context.childManager.callParentAsyncFunction( + "contentScripts.register", + [options] + ); + + const registeredScript = new ContentScriptChild(context, scriptId); + + return Cu.cloneInto(registeredScript.api(), context.cloneScope, { + cloneFunctions: true, + }); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-declarativeNetRequest.js b/toolkit/components/extensions/child/ext-declarativeNetRequest.js new file mode 100644 index 0000000000..82028c6105 --- /dev/null +++ b/toolkit/components/extensions/child/ext-declarativeNetRequest.js @@ -0,0 +1,35 @@ +/* -*- 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", +}); + +this.declarativeNetRequest = class extends ExtensionAPI { + getAPI(context) { + return { + declarativeNetRequest: { + get GUARANTEED_MINIMUM_STATIC_RULES() { + return ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES; + }, + get MAX_NUMBER_OF_STATIC_RULESETS() { + return ExtensionDNRLimits.MAX_NUMBER_OF_STATIC_RULESETS; + }, + get MAX_NUMBER_OF_ENABLED_STATIC_RULESETS() { + return ExtensionDNRLimits.MAX_NUMBER_OF_ENABLED_STATIC_RULESETS; + }, + get MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES() { + return ExtensionDNRLimits.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES; + }, + get MAX_NUMBER_OF_REGEX_RULES() { + return ExtensionDNRLimits.MAX_NUMBER_OF_REGEX_RULES; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-extension.js b/toolkit/components/extensions/child/ext-extension.js new file mode 100644 index 0000000000..f4024086e4 --- /dev/null +++ b/toolkit/components/extensions/child/ext-extension.js @@ -0,0 +1,78 @@ +/* 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"; + +this.extension = class extends ExtensionAPI { + getAPI(context) { + let api = { + getURL(url) { + return context.extension.baseURI.resolve(url); + }, + + get lastError() { + return context.lastError; + }, + + get inIncognitoContext() { + return context.incognito; + }, + }; + + if (context.envType === "addon_child") { + api.getViews = function (fetchProperties) { + let result = Cu.cloneInto([], context.cloneScope); + + for (let view of context.extension.views) { + if (!view.active) { + continue; + } + if (!context.principal.subsumes(view.principal)) { + continue; + } + + if (fetchProperties !== null) { + if ( + fetchProperties.type !== null && + view.viewType != fetchProperties.type + ) { + continue; + } + + if (fetchProperties.windowId !== null) { + let bc = view.contentWindow?.docShell?.browserChild; + let windowId = + view.viewType !== "background" + ? bc?.chromeOuterWindowID ?? -1 + : -1; + if (windowId !== fetchProperties.windowId) { + continue; + } + } + + if ( + fetchProperties.tabId !== null && + view.tabId != fetchProperties.tabId + ) { + continue; + } + } + + // Do not include extension popups contexts while their document + // is blocked on parsing during its preloading state + // (See Bug 1748808). + if (context.extension.hasContextBlockedParsingDocument(view)) { + continue; + } + + result.push(view.contentWindow); + } + + return result; + }; + } + + return { extension: api }; + } +}; diff --git a/toolkit/components/extensions/child/ext-identity.js b/toolkit/components/extensions/child/ext-identity.js new file mode 100644 index 0000000000..9218065322 --- /dev/null +++ b/toolkit/components/extensions/child/ext-identity.js @@ -0,0 +1,84 @@ +/* -*- 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 { Constructor: CC } = Components; + +ChromeUtils.defineESModuleGetters(this, { + CommonUtils: "resource://services-common/utils.sys.mjs", +}); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "redirectDomain", + "extensions.webextensions.identity.redirectDomain" +); + +let CryptoHash = CC( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "TextEncoder"]); + +const computeHash = str => { + let byteArr = new TextEncoder().encode(str); + let hash = new CryptoHash("sha1"); + hash.update(byteArr, byteArr.length); + return CommonUtils.bytesAsHex(hash.finish(false)); +}; + +this.identity = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + return { + identity: { + getRedirectURL: function (path = "") { + let hash = computeHash(extension.id); + let url = new URL(`https://${hash}.${redirectDomain}/`); + url.pathname = path; + return url.href; + }, + launchWebAuthFlow: function (details) { + // Validate the url and retreive redirect_uri if it was provided. + let url, redirectURI; + let baseRedirectURL = this.getRedirectURL(); + + // Allow using loopback address for native OAuth flows as some + // providers do not accept the URL provided by getRedirectURL. + // For more context, see bug 1635344. + let loopbackURL = `http://127.0.0.1/mozoauth2/${computeHash( + extension.id + )}`; + try { + url = new URL(details.url); + } catch (e) { + return Promise.reject({ message: "details.url is invalid" }); + } + try { + redirectURI = new URL( + url.searchParams.get("redirect_uri") || baseRedirectURL + ); + if ( + !redirectURI.href.startsWith(baseRedirectURL) && + !redirectURI.href.startsWith(loopbackURL) + ) { + return Promise.reject({ message: "redirect_uri not allowed" }); + } + } catch (e) { + return Promise.reject({ message: "redirect_uri is invalid" }); + } + + return context.childManager.callParentAsyncFunction( + "identity.launchWebAuthFlowInParent", + [details, redirectURI.href] + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-runtime.js b/toolkit/components/extensions/child/ext-runtime.js new file mode 100644 index 0000000000..8cf5c445e3 --- /dev/null +++ b/toolkit/components/extensions/child/ext-runtime.js @@ -0,0 +1,143 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +/* eslint-disable jsdoc/check-param-names */ +/** + * With optional arguments on both ends, this case is ambiguous: + * runtime.sendMessage("string", {} or nullish) + * + * Sending a message within the extension is more common than sending + * an empty object to another extension, so we prefer that conclusion. + * + * @param {string?} [extensionId] + * @param {any} message + * @param {object?} [options] + * @param {Function} [callback] + * @returns {{extensionId: string?, message: any, callback: Function?}} + */ +/* eslint-enable jsdoc/check-param-names */ +function parseBonkersArgs(...args) { + let Error = ExtensionUtils.ExtensionError; + let callback = typeof args[args.length - 1] === "function" && args.pop(); + + // We don't support any options anymore, so only an empty object is valid. + function validOptions(v) { + return v == null || (typeof v === "object" && !Object.keys(v).length); + } + + if (args.length === 1 || (args.length === 2 && validOptions(args[1]))) { + // Interpret as passing null for extensionId (message within extension). + args.unshift(null); + } + let [extensionId, message, options] = args; + + if (!args.length) { + throw new Error("runtime.sendMessage's message argument is missing"); + } else if (!validOptions(options)) { + throw new Error("runtime.sendMessage's options argument is invalid"); + } else if (args.length === 4 && args[3] && !callback) { + throw new Error("runtime.sendMessage's last argument is not a function"); + } else if (args[3] != null || args.length > 4) { + throw new Error("runtime.sendMessage received too many arguments"); + } else if (extensionId && typeof extensionId !== "string") { + throw new Error("runtime.sendMessage's extensionId argument is invalid"); + } + return { extensionId, message, callback }; +} + +this.runtime = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + + return { + runtime: { + onConnect: context.messenger.onConnect.api(), + onMessage: context.messenger.onMessage.api(), + + onConnectExternal: context.messenger.onConnectEx.api(), + onMessageExternal: context.messenger.onMessageEx.api(), + + connect(extensionId, options) { + let name = options?.name ?? ""; + return context.messenger.connect({ name, extensionId }); + }, + + sendMessage(...args) { + let arg = parseBonkersArgs(...args); + return context.messenger.sendRuntimeMessage(arg); + }, + + connectNative(name) { + return context.messenger.connect({ name, native: true }); + }, + + sendNativeMessage(nativeApp, message) { + return context.messenger.sendNativeMessage(nativeApp, message); + }, + + get lastError() { + return context.lastError; + }, + + getManifest() { + return Cu.cloneInto(extension.manifest, context.cloneScope); + }, + + id: extension.id, + + getURL(url) { + return extension.baseURI.resolve(url); + }, + + getFrameId(target) { + let frameId = WebNavigationFrames.getFromWindow(target); + if (frameId >= 0) { + return frameId; + } + // Not a WindowProxy, perhaps an embedder element? + + let type; + try { + type = Cu.getClassName(target, true); + } catch (e) { + // Not a valid object, will throw below. + } + + const embedderTypes = [ + "HTMLIFrameElement", + "HTMLFrameElement", + "HTMLEmbedElement", + "HTMLObjectElement", + ]; + + if (embedderTypes.includes(type)) { + if (!target.browsingContext) { + return -1; + } + return WebNavigationFrames.getFrameId(target.browsingContext); + } + + throw new ExtensionUtils.ExtensionError("Invalid argument"); + }, + }, + }; + } + + getAPIObjectForRequest(context, request) { + if (request.apiObjectType === "Port") { + const port = context.messenger.getPortById(request.apiObjectId); + if (!port) { + throw new Error(`Port API object not found: ${request}`); + } + return port.api; + } + + throw new Error(`Unexpected apiObjectType: ${request}`); + } +}; diff --git a/toolkit/components/extensions/child/ext-scripting.js b/toolkit/components/extensions/child/ext-scripting.js new file mode 100644 index 0000000000..cce587227f --- /dev/null +++ b/toolkit/components/extensions/child/ext-scripting.js @@ -0,0 +1,49 @@ +/* -*- 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 { ExtensionError } = ExtensionUtils; + +this.scripting = class extends ExtensionAPI { + getAPI(context) { + return { + scripting: { + executeScript: async details => { + let { func, args, ...parentDetails } = details; + + if (details.files) { + if (details.args) { + throw new ExtensionError( + "'args' may not be used with file injections." + ); + } + } + // `files` and `func` are mutually exclusive but that is checked in + // the parent (in `execute()`). + if (func) { + try { + const serializedArgs = args + ? JSON.stringify(args).slice(1, -1) + : ""; + // This is a prop that we compute here and pass to the parent. + parentDetails.func = `(${func.toString()})(${serializedArgs});`; + } catch (e) { + throw new ExtensionError("Unserializable arguments."); + } + } else { + parentDetails.func = null; + } + + return context.childManager.callParentAsyncFunction( + "scripting.executeScriptInternal", + [parentDetails] + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-storage.js b/toolkit/components/extensions/child/ext-storage.js new file mode 100644 index 0000000000..2d10964d0a --- /dev/null +++ b/toolkit/components/extensions/child/ext-storage.js @@ -0,0 +1,368 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", +}); + +// Wrap a storage operation in a TelemetryStopWatch. +async function measureOp(telemetryMetric, extension, fn) { + const stopwatchKey = {}; + telemetryMetric.stopwatchStart(extension, stopwatchKey); + try { + let result = await fn(); + telemetryMetric.stopwatchFinish(extension, stopwatchKey); + return result; + } catch (err) { + telemetryMetric.stopwatchCancel(extension, stopwatchKey); + throw err; + } +} + +this.storage = class extends ExtensionAPI { + getLocalFileBackend(context, { deserialize, serialize }) { + return { + get(keys) { + return measureOp( + ExtensionTelemetry.storageLocalGetJson, + context.extension, + () => { + return context.childManager + .callParentAsyncFunction("storage.local.JSONFileBackend.get", [ + serialize(keys), + ]) + .then(deserialize); + } + ); + }, + set(items) { + return measureOp( + ExtensionTelemetry.storageLocalSetJson, + context.extension, + () => { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.set", + [serialize(items)] + ); + } + ); + }, + remove(keys) { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.remove", + [serialize(keys)] + ); + }, + clear() { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.clear", + [] + ); + }, + }; + } + + getLocalIDBBackend(context, { fireOnChanged, serialize, storagePrincipal }) { + let dbPromise; + async function getDB() { + if (dbPromise) { + return dbPromise; + } + + const persisted = context.extension.hasPermission("unlimitedStorage"); + dbPromise = ExtensionStorageIDB.open(storagePrincipal, persisted).catch( + err => { + // Reset the cached promise if it has been rejected, so that the next + // API call is going to retry to open the DB. + dbPromise = null; + throw err; + } + ); + + return dbPromise; + } + + return { + get(keys) { + return measureOp( + ExtensionTelemetry.storageLocalGetIdb, + context.extension, + async () => { + const db = await getDB(); + return db.get(keys); + } + ); + }, + set(items) { + function serialize(name, anonymizedName, value) { + return ExtensionStorage.serialize( + `set/${context.extension.id}/${name}`, + `set/${context.extension.id}/${anonymizedName}`, + value + ); + } + + return measureOp( + ExtensionTelemetry.storageLocalSetIdb, + context.extension, + async () => { + const db = await getDB(); + const changes = await db.set(items, { + serialize, + }); + + if (changes) { + fireOnChanged(changes); + } + } + ); + }, + async remove(keys) { + const db = await getDB(); + const changes = await db.remove(keys); + + if (changes) { + fireOnChanged(changes); + } + }, + async clear() { + const db = await getDB(); + const changes = await db.clear(context.extension); + + if (changes) { + fireOnChanged(changes); + } + }, + }; + } + + getAPI(context) { + const { extension } = context; + const serialize = ExtensionStorage.serializeForContext.bind(null, context); + const deserialize = ExtensionStorage.deserializeForContext.bind( + null, + context + ); + + // onChangedName is "storage.onChanged", "storage.sync.onChanged", etc. + function makeOnChangedEventTarget(onChangedName) { + return new EventManager({ + context, + name: onChangedName, + register: fire => { + let onChanged = (data, area) => { + let changes = new context.cloneScope.Object(); + for (let [key, value] of Object.entries(data)) { + changes[key] = deserialize(value); + } + if (area) { + // storage.onChanged includes the area. + fire.raw(changes, area); + } else { + // StorageArea.onChanged doesn't include the area. + fire.raw(changes); + } + }; + + let parent = context.childManager.getParentEvent(onChangedName); + parent.addListener(onChanged); + return () => { + parent.removeListener(onChanged); + }; + }, + }).api(); + } + + function sanitize(items) { + // The schema validator already takes care of arrays (which are only allowed + // to contain strings). Strings and null are safe values. + if (typeof items != "object" || items === null || Array.isArray(items)) { + return items; + } + // If we got here, then `items` is an object generated by `ObjectType`'s + // `normalize` method from Schemas.jsm. The object returned by `normalize` + // lives in this compartment, while the values live in compartment of + // `context.contentWindow`. The `sanitize` method runs with the principal + // of `context`, so we cannot just use `ExtensionStorage.sanitize` because + // it is not allowed to access properties of `items`. + // So we enumerate all properties and sanitize each value individually. + let sanitized = {}; + for (let [key, value] of Object.entries(items)) { + sanitized[key] = ExtensionStorage.sanitize(value, context); + } + return sanitized; + } + + function fireOnChanged(changes) { + // This call is used (by the storage.local API methods for the IndexedDB backend) to fire a storage.onChanged event, + // it uses the underlying message manager since the child context (or its ProxyContentParent counterpart + // running in the main process) may be gone by the time we call this, and so we can't use the childManager + // abstractions (e.g. callParentAsyncFunction or callParentFunctionNoReturn). + Services.cpmm.sendAsyncMessage( + `Extension:StorageLocalOnChanged:${extension.uuid}`, + changes + ); + } + + // If the selected backend for the extension is not known yet, we have to lazily detect it + // by asking to the main process (as soon as the storage.local API has been accessed for + // the first time). + const getStorageLocalBackend = async () => { + const { backendEnabled, storagePrincipal } = + await ExtensionStorageIDB.selectBackend(context); + + if (!backendEnabled) { + return this.getLocalFileBackend(context, { deserialize, serialize }); + } + + return this.getLocalIDBBackend(context, { + storagePrincipal, + fireOnChanged, + serialize, + }); + }; + + // Synchronously select the backend if it is already known. + let selectedBackend; + + const useStorageIDBBackend = extension.getSharedData("storageIDBBackend"); + if (useStorageIDBBackend === false) { + selectedBackend = this.getLocalFileBackend(context, { + deserialize, + serialize, + }); + } else if (useStorageIDBBackend === true) { + selectedBackend = this.getLocalIDBBackend(context, { + storagePrincipal: extension.getSharedData("storageIDBPrincipal"), + fireOnChanged, + serialize, + }); + } + + let promiseStorageLocalBackend; + + // Generate the backend-agnostic local API wrapped methods. + const local = { + onChanged: makeOnChangedEventTarget("storage.local.onChanged"), + }; + for (let method of ["get", "set", "remove", "clear"]) { + local[method] = async function (...args) { + try { + // Discover the selected backend if it is not known yet. + if (!selectedBackend) { + if (!promiseStorageLocalBackend) { + promiseStorageLocalBackend = getStorageLocalBackend().catch( + err => { + // Clear the cached promise if it has been rejected. + promiseStorageLocalBackend = null; + throw err; + } + ); + } + + // If the storage.local method is not 'get' (which doesn't change any of the stored data), + // fall back to call the method in the parent process, so that it can be completed even + // if this context has been destroyed in the meantime. + if (method !== "get") { + // Let the outer try to catch rejections returned by the backend methods. + try { + const result = + await context.childManager.callParentAsyncFunction( + "storage.local.callMethodInParentProcess", + [method, args] + ); + return result; + } catch (err) { + // Just return the rejection as is, the error has been normalized in the + // parent process by callMethodInParentProcess and the original error + // logged in the browser console. + return Promise.reject(err); + } + } + + // Get the selected backend and cache it for the next API calls from this context. + selectedBackend = await promiseStorageLocalBackend; + } + + // Let the outer try to catch rejections returned by the backend methods. + const result = await selectedBackend[method](...args); + return result; + } catch (err) { + throw ExtensionStorageIDB.normalizeStorageError({ + error: err, + extensionId: extension.id, + storageMethod: method, + }); + } + }; + } + + return { + storage: { + local, + + session: { + async get(keys) { + return deserialize( + await context.childManager.callParentAsyncFunction( + "storage.session.get", + [serialize(keys)] + ) + ); + }, + set(items) { + return context.childManager.callParentAsyncFunction( + "storage.session.set", + [serialize(items)] + ); + }, + onChanged: makeOnChangedEventTarget("storage.session.onChanged"), + }, + + sync: { + get(keys) { + keys = sanitize(keys); + return context.childManager.callParentAsyncFunction( + "storage.sync.get", + [keys] + ); + }, + set(items) { + items = sanitize(items); + return context.childManager.callParentAsyncFunction( + "storage.sync.set", + [items] + ); + }, + onChanged: makeOnChangedEventTarget("storage.sync.onChanged"), + }, + + managed: { + get(keys) { + return context.childManager + .callParentAsyncFunction("storage.managed.get", [serialize(keys)]) + .then(deserialize); + }, + set(items) { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + remove(keys) { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + clear() { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + + onChanged: makeOnChangedEventTarget("storage.managed.onChanged"), + }, + + onChanged: makeOnChangedEventTarget("storage.onChanged"), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-test.js b/toolkit/components/extensions/child/ext-test.js new file mode 100644 index 0000000000..a4178b63ff --- /dev/null +++ b/toolkit/components/extensions/child/ext-test.js @@ -0,0 +1,371 @@ +/* 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"; + +ChromeUtils.defineLazyGetter(this, "isXpcshell", function () { + return Services.env.exists("XPCSHELL_TEST_PROFILE_DIR"); +}); + +/** + * Checks whether the given error matches the given expectations. + * + * @param {*} error + * The error to check. + * @param {string | RegExp | Function | null} expectedError + * The expectation to check against. If this parameter is: + * + * - a string, the error message must exactly equal the string. + * - a regular expression, it must match the error message. + * - a function, it is called with the error object and its + * return value is returned. + * @param {BaseContext} context + * + * @returns {boolean} + * True if the error matches the expected error. + */ +const errorMatches = (error, expectedError, context) => { + if ( + typeof error === "object" && + error !== null && + !context.principal.subsumes(Cu.getObjectPrincipal(error)) + ) { + Cu.reportError("Error object belongs to the wrong scope."); + return false; + } + + if (typeof expectedError === "function") { + return context.runSafeWithoutClone(expectedError, error); + } + + if ( + typeof error !== "object" || + error == null || + typeof error.message !== "string" + ) { + return false; + } + + if (typeof expectedError === "string") { + return error.message === expectedError; + } + + try { + return expectedError.test(error.message); + } catch (e) { + Cu.reportError(e); + } + + return false; +}; + +// Checks whether |v| should use string serialization instead of JSON. +function useStringInsteadOfJSON(v) { + return ( + // undefined to string, or else it is omitted from object after stringify. + v === undefined || + // Values that would have become null. + (typeof v === "number" && (isNaN(v) || !isFinite(v))) + ); +} + +// A very strict deep equality comparator that throws for unsupported values. +// For context, see https://bugzilla.mozilla.org/show_bug.cgi?id=1782816#c2 +function deepEquals(a, b) { + // Some values don't have a JSON representation. To disambiguate from null or + // regular strings, we prepend this prefix instead. + const NON_JSON_PREFIX = "#NOT_JSON_SERIALIZABLE#"; + + function replacer(key, value) { + if (typeof value == "object" && value !== null && !Array.isArray(value)) { + const cls = ChromeUtils.getClassName(value); + if (cls === "Object") { + // Return plain object with keys sorted in a predictable order. + return Object.fromEntries( + Object.keys(value) + .sort() + .map(k => [k, value[k]]) + ); + } + // Just throw to avoid potentially inaccurate serializations (e.g. {}). + throw new ExtensionUtils.ExtensionError(`Unsupported obj type: ${cls}`); + } + + if (useStringInsteadOfJSON(value)) { + return `${NON_JSON_PREFIX}${value}`; + } + return value; + } + return JSON.stringify(a, replacer) === JSON.stringify(b, replacer); +} + +/** + * Serializes the given value for use in informative assertion messages. + * + * @param {*} value + * @returns {string} + */ +const toSource = value => { + function cannotJSONserialize(v) { + return ( + useStringInsteadOfJSON(v) || + // Not a plain object. E.g. [object X], /regexp/, etc. + (typeof v == "object" && + v !== null && + !Array.isArray(v) && + ChromeUtils.getClassName(v) !== "Object") + ); + } + try { + if (cannotJSONserialize(value)) { + return String(value); + } + + const replacer = (k, v) => (cannotJSONserialize(v) ? String(v) : v); + return JSON.stringify(value, replacer); + } catch (e) { + return "<unknown>"; + } +}; + +this.test = class extends ExtensionAPI { + getAPI(context) { + const { extension } = context; + + function getStack(savedFrame = null) { + if (savedFrame) { + return ChromeUtils.createError("", savedFrame).stack.replace( + /^/gm, + " " + ); + } + return new context.Error().stack.replace(/^/gm, " "); + } + + function assertTrue(value, msg) { + extension.emit( + "test-result", + Boolean(value), + String(msg), + getStack(context.getCaller()) + ); + } + + class TestEventManager extends EventManager { + constructor(...args) { + super(...args); + + // A map to keep track of the listeners wrappers being added in + // addListener (the wrapper will be needed to be able to remove + // the listener from this EventManager instance if the extension + // does call test.onMessage.removeListener). + this._listenerWrappers = new Map(); + context.callOnClose({ + close: () => this._listenerWrappers.clear(), + }); + } + + addListener(callback, ...args) { + const listenerWrapper = function (...args) { + try { + callback.call(this, ...args); + } catch (e) { + assertTrue(false, `${e}\n${e.stack}`); + } + }; + super.addListener(listenerWrapper, ...args); + this._listenerWrappers.set(callback, listenerWrapper); + } + + removeListener(callback) { + if (!this._listenerWrappers.has(callback)) { + return; + } + + super.removeListener(this._listenerWrappers.get(callback)); + this._listenerWrappers.delete(callback); + } + } + + if (!Cu.isInAutomation && !isXpcshell) { + return { test: {} }; + } + + return { + test: { + withHandlingUserInput(callback) { + // TODO(Bug 1598804): remove this once we don't expose anymore the + // entire test API namespace based on an environment variable. + if (!Cu.isInAutomation) { + // This dangerous method should only be available if the + // automation pref is set, which is the case in browser tests. + throw new ExtensionUtils.ExtensionError( + "withHandlingUserInput can only be called in automation" + ); + } + ExtensionCommon.withHandlingUserInput( + context.contentWindow, + callback + ); + }, + + sendMessage(...args) { + extension.emit("test-message", ...args); + }, + + notifyPass(msg) { + extension.emit("test-done", true, msg, getStack(context.getCaller())); + }, + + notifyFail(msg) { + extension.emit( + "test-done", + false, + msg, + getStack(context.getCaller()) + ); + }, + + log(msg) { + extension.emit("test-log", true, msg, getStack(context.getCaller())); + }, + + fail(msg) { + assertTrue(false, msg); + }, + + succeed(msg) { + assertTrue(true, msg); + }, + + assertTrue(value, msg) { + assertTrue(value, msg); + }, + + assertFalse(value, msg) { + assertTrue(!value, msg); + }, + + assertDeepEq(expected, actual, msg) { + // The bindings generated by Schemas.jsm accepts any input, but the + // WebIDL-generated binding expects a structurally cloneable input. + // To ensure consistent behavior regardless of which mechanism was + // used, verify that the inputs are structurally cloneable. + // These will throw if the values cannot be cloned. + function ensureStructurallyCloneable(v) { + if (typeof v == "object" && v !== null) { + // Waive xrays to unhide callable members, so that cloneInto will + // throw if needed. + v = ChromeUtils.waiveXrays(v); + } + new StructuredCloneHolder("test.assertEq", null, v, globalThis); + } + // When WebIDL bindings are used, the objects are already cloned + // structurally, so we don't need to check again. + if (!context.useWebIDLBindings) { + ensureStructurallyCloneable(expected); + ensureStructurallyCloneable(actual); + } + + extension.emit( + "test-eq", + deepEquals(actual, expected), + String(msg), + toSource(expected), + toSource(actual), + getStack(context.getCaller()) + ); + }, + + assertEq(expected, actual, msg) { + let equal = expected === actual; + + expected = String(expected); + actual = String(actual); + + if (!equal && expected === actual) { + actual += " (different)"; + } + extension.emit( + "test-eq", + equal, + String(msg), + expected, + actual, + getStack(context.getCaller()) + ); + }, + + assertRejects(promise, expectedError, msg) { + // Wrap in a native promise for consistency. + promise = Promise.resolve(promise); + + return promise.then( + result => { + let message = `Promise resolved, expected rejection '${toSource( + expectedError + )}'`; + if (msg) { + message += `: ${msg}`; + } + assertTrue(false, message); + }, + error => { + let expected = toSource(expectedError); + let message = `got '${toSource(error)}'`; + if (msg) { + message += `: ${msg}`; + } + + assertTrue( + errorMatches(error, expectedError, context), + `Promise rejected, expecting rejection to match '${expected}', ${message}` + ); + } + ); + }, + + assertThrows(func, expectedError, msg) { + try { + func(); + + let message = `Function did not throw, expected error '${toSource( + expectedError + )}'`; + if (msg) { + message += `: ${msg}`; + } + assertTrue(false, message); + } catch (error) { + let expected = toSource(expectedError); + let message = `got '${toSource(error)}'`; + if (msg) { + message += `: ${msg}`; + } + + assertTrue( + errorMatches(error, expectedError, context), + `Function threw, expecting error to match '${expected}', ${message}` + ); + } + }, + + onMessage: new TestEventManager({ + context, + name: "test.onMessage", + register: fire => { + let handler = (event, ...args) => { + fire.async(...args); + }; + + extension.on("test-harness-message", handler); + return () => { + extension.off("test-harness-message", handler); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-toolkit.js b/toolkit/components/extensions/child/ext-toolkit.js new file mode 100644 index 0000000000..0786880961 --- /dev/null +++ b/toolkit/components/extensions/child/ext-toolkit.js @@ -0,0 +1,84 @@ +/* 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"; + +global.EventManager = ExtensionCommon.EventManager; + +extensions.registerModules({ + backgroundPage: { + url: "chrome://extensions/content/child/ext-backgroundPage.js", + scopes: ["addon_child"], + manifest: ["background"], + paths: [ + ["extension", "getBackgroundPage"], + ["runtime", "getBackgroundPage"], + ], + }, + contentScripts: { + url: "chrome://extensions/content/child/ext-contentScripts.js", + scopes: ["addon_child"], + paths: [["contentScripts"]], + }, + declarativeNetRequest: { + url: "chrome://extensions/content/child/ext-declarativeNetRequest.js", + scopes: ["addon_child"], + paths: [["declarativeNetRequest"]], + }, + extension: { + url: "chrome://extensions/content/child/ext-extension.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["extension"]], + }, + i18n: { + url: "chrome://extensions/content/parent/ext-i18n.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["i18n"]], + }, + runtime: { + url: "chrome://extensions/content/child/ext-runtime.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["runtime"]], + }, + scripting: { + url: "chrome://extensions/content/child/ext-scripting.js", + scopes: ["addon_child"], + paths: [["scripting"]], + }, + storage: { + url: "chrome://extensions/content/child/ext-storage.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["storage"]], + }, + test: { + url: "chrome://extensions/content/child/ext-test.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["test"]], + }, + userScripts: { + url: "chrome://extensions/content/child/ext-userScripts.js", + scopes: ["addon_child"], + paths: [["userScripts"]], + }, + userScriptsContent: { + url: "chrome://extensions/content/child/ext-userScripts-content.js", + scopes: ["content_child"], + paths: [["userScripts", "onBeforeScript"]], + }, + webRequest: { + url: "chrome://extensions/content/child/ext-webRequest.js", + scopes: ["addon_child"], + paths: [["webRequest"]], + }, +}); + +if (AppConstants.MOZ_BUILD_APP === "browser") { + extensions.registerModules({ + identity: { + url: "chrome://extensions/content/child/ext-identity.js", + scopes: ["addon_child"], + paths: [["identity"]], + }, + }); +} 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(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-userScripts.js b/toolkit/components/extensions/child/ext-userScripts.js new file mode 100644 index 0000000000..66cfeb0906 --- /dev/null +++ b/toolkit/components/extensions/child/ext-userScripts.js @@ -0,0 +1,192 @@ +/* -*- 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.`; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "userScriptsEnabled", + USERSCRIPT_PREFNAME, + false +); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +Cu.importGlobalProperties(["crypto", "TextEncoder"]); + +var { DefaultMap, ExtensionError, getUniqueId } = ExtensionUtils; + +/** + * Represents a registered userScript in the child extension process. + * + * @param {ExtensionPageContextChild} context + * The extension context which has registered the user script. + * @param {string} scriptId + * An unique id that represents the registered user script + * (generated and used internally to identify it across the different processes). + */ +class UserScriptChild { + constructor({ context, scriptId, onScriptUnregister }) { + this.context = context; + this.scriptId = scriptId; + this.onScriptUnregister = onScriptUnregister; + this.unregistered = false; + } + + async unregister() { + if (this.unregistered) { + throw new ExtensionError("User script already unregistered"); + } + + this.unregistered = true; + + await this.context.childManager.callParentAsyncFunction( + "userScripts.unregister", + [this.scriptId] + ); + + this.context = null; + + this.onScriptUnregister(); + } + + api() { + const { context } = this; + + // Returns the RegisteredUserScript API object. + return { + unregister: () => { + return context.wrapPromise(this.unregister()); + }, + }; + } +} + +this.userScripts = class extends ExtensionAPI { + getAPI(context) { + // Cache of the script code already converted into blob urls: + // Map<textHash, blobURLs> + const blobURLsByHash = new Map(); + + // Keep track of the userScript that are sharing the same blob urls, + // so that we can revoke any blob url that is not used by a registered + // userScripts: + // Map<blobURL, Set<scriptId>> + const userScriptsByBlobURL = new DefaultMap(() => new Set()); + + function revokeBlobURLs(scriptId, options) { + let revokedUrls = new Set(); + + for (let url of options.js) { + if (userScriptsByBlobURL.has(url)) { + let scriptIds = userScriptsByBlobURL.get(url); + scriptIds.delete(scriptId); + + if (scriptIds.size === 0) { + revokedUrls.add(url); + userScriptsByBlobURL.delete(url); + context.cloneScope.URL.revokeObjectURL(url); + } + } + } + + // Remove all the removed urls from the map of known computed hashes. + for (let [hash, url] of blobURLsByHash) { + if (revokedUrls.has(url)) { + blobURLsByHash.delete(hash); + } + } + } + + // Convert a script code string into a blob URL (and use a cached one + // if the script hash is already associated to a blob URL). + const getBlobURL = async (text, scriptId) => { + // Compute the hash of the js code string and reuse the blob url if we already have + // for the same hash. + const buffer = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(text) + ); + const hash = String.fromCharCode(...new Uint16Array(buffer)); + + let blobURL = blobURLsByHash.get(hash); + + if (blobURL) { + userScriptsByBlobURL.get(blobURL).add(scriptId); + return blobURL; + } + + const blob = new context.cloneScope.Blob([text], { + type: "text/javascript", + }); + blobURL = context.cloneScope.URL.createObjectURL(blob); + + // Start to track this blob URL. + userScriptsByBlobURL.get(blobURL).add(scriptId); + + blobURLsByHash.set(hash, blobURL); + + return blobURL; + }; + + function convertToAPIObject(scriptId, options) { + const registeredScript = new UserScriptChild({ + context, + scriptId, + onScriptUnregister: () => revokeBlobURLs(scriptId, options), + }); + + const scriptAPI = Cu.cloneInto( + registeredScript.api(), + context.cloneScope, + { cloneFunctions: true } + ); + return scriptAPI; + } + + // Revoke all the created blob urls once the context is destroyed. + context.callOnClose({ + close() { + if (!context.cloneScope) { + return; + } + + for (let blobURL of blobURLsByHash.values()) { + context.cloneScope.URL.revokeObjectURL(blobURL); + } + }, + }); + + return { + userScripts: { + register(options) { + if (!userScriptsEnabled) { + throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG); + } + + let scriptId = getUniqueId(); + return context.cloneScope.Promise.resolve().then(async () => { + options.scriptId = scriptId; + options.js = await Promise.all( + options.js.map(js => { + return js.file || getBlobURL(js.code, scriptId); + }) + ); + + await context.childManager.callParentAsyncFunction( + "userScripts.register", + [options] + ); + + return convertToAPIObject(scriptId, options); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-webRequest.js b/toolkit/components/extensions/child/ext-webRequest.js new file mode 100644 index 0000000000..49bdd3f232 --- /dev/null +++ b/toolkit/components/extensions/child/ext-webRequest.js @@ -0,0 +1,119 @@ +/* -*- 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 { ExtensionError } = ExtensionUtils; + +this.webRequest = class extends ExtensionAPI { + STREAM_FILTER_INACTIVE_STATUSES = ["closed", "disconnected", "failed"]; + + hasActiveStreamFilter(filtersWeakSet) { + const iter = ChromeUtils.nondeterministicGetWeakSetKeys(filtersWeakSet); + for (let filter of iter) { + if (!this.STREAM_FILTER_INACTIVE_STATUSES.includes(filter.status)) { + return true; + } + } + return false; + } + + watchStreamFilterSuspendCancel({ + context, + filters, + onSuspend, + onSuspendCanceled, + }) { + if ( + !context.isBackgroundContext || + context.extension.persistentBackground !== false + ) { + return; + } + + const { extension } = context; + const cancelSuspendOnActiveStreamFilter = () => + this.hasActiveStreamFilter(filters); + context.callOnClose({ + close() { + extension.off( + "internal:stream-filter-suspend-cancel", + cancelSuspendOnActiveStreamFilter + ); + extension.off("background-script-suspend", onSuspend); + extension.off("background-script-suspend-canceled", onSuspend); + }, + }); + extension.on( + "internal:stream-filter-suspend-cancel", + cancelSuspendOnActiveStreamFilter + ); + extension.on("background-script-suspend", onSuspend); + extension.on("background-script-suspend-canceled", onSuspendCanceled); + } + + getAPI(context) { + let filters = new WeakSet(); + + context.callOnClose({ + close() { + for (let filter of ChromeUtils.nondeterministicGetWeakSetKeys( + filters + )) { + try { + filter.disconnect(); + } catch (e) { + // Ignore. + } + } + }, + }); + + let isSuspending = false; + this.watchStreamFilterSuspendCancel({ + context, + filters, + onSuspend: () => (isSuspending = true), + onSuspendCanceled: () => (isSuspending = false), + }); + + function filterResponseData(requestId) { + if (isSuspending) { + throw new ExtensionError( + "filterResponseData method calls forbidden while background extension global is suspending" + ); + } + requestId = parseInt(requestId, 10); + + let streamFilter = context.cloneScope.StreamFilter.create( + requestId, + context.extension.id + ); + + filters.add(streamFilter); + return streamFilter; + } + + const webRequest = {}; + + // For extensions with manifest_version >= 3, an additional webRequestFilterResponse permission + // is required to get access to the webRequest.filterResponseData API method. + if ( + context.extension.manifestVersion < 3 || + context.extension.hasPermission("webRequestFilterResponse") + ) { + webRequest.filterResponseData = filterResponseData; + } else { + webRequest.filterResponseData = () => { + throw new ExtensionError( + 'Missing required "webRequestFilterResponse" permission' + ); + }; + } + + return { webRequest }; + } +}; |