summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ExtensionContent.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/ExtensionContent.sys.mjs')
-rw-r--r--toolkit/components/extensions/ExtensionContent.sys.mjs1308
1 files changed, 1308 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionContent.sys.mjs b/toolkit/components/extensions/ExtensionContent.sys.mjs
new file mode 100644
index 0000000000..131d555bf0
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionContent.sys.mjs
@@ -0,0 +1,1308 @@
+/* -*- 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionProcessScript:
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs",
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+ LanguageDetector:
+ "resource://gre/modules/translation/LanguageDetector.sys.mjs",
+ Schemas: "resource://gre/modules/Schemas.sys.mjs",
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "styleSheetService",
+ "@mozilla.org/content/style-sheet-service;1",
+ "nsIStyleSheetService"
+);
+
+const Timer = Components.Constructor(
+ "@mozilla.org/timer;1",
+ "nsITimer",
+ "initWithCallback"
+);
+
+const ScriptError = Components.Constructor(
+ "@mozilla.org/scripterror;1",
+ "nsIScriptError",
+ "initWithWindowID"
+);
+
+import {
+ ExtensionChild,
+ ExtensionActivityLogChild,
+} from "resource://gre/modules/ExtensionChild.sys.mjs";
+import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+const {
+ DefaultMap,
+ DefaultWeakMap,
+ getInnerWindowID,
+ promiseDocumentIdle,
+ promiseDocumentLoaded,
+ promiseDocumentReady,
+} = ExtensionUtils;
+
+const {
+ BaseContext,
+ CanOfAPIs,
+ SchemaAPIManager,
+ defineLazyGetter,
+ redefineGetter,
+ runSafeSyncWithoutClone,
+} = ExtensionCommon;
+
+const { BrowserExtensionContent, ChildAPIManager, Messenger } = ExtensionChild;
+
+ChromeUtils.defineLazyGetter(lazy, "isContentScriptProcess", () => {
+ return (
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT ||
+ !WebExtensionPolicy.useRemoteWebExtensions ||
+ // Thunderbird still loads some content in the parent process.
+ AppConstants.MOZ_APP_NAME == "thunderbird"
+ );
+});
+
+var DocumentManager;
+
+const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";
+
+var apiManager = new (class extends SchemaAPIManager {
+ constructor() {
+ super("content", lazy.Schemas);
+ this.initialized = false;
+ }
+
+ lazyInit() {
+ if (!this.initialized) {
+ this.initialized = true;
+ this.initGlobal();
+ for (let { value } of Services.catMan.enumerateCategory(
+ CATEGORY_EXTENSION_SCRIPTS_CONTENT
+ )) {
+ this.loadScript(value);
+ }
+ }
+ }
+})();
+
+const SCRIPT_EXPIRY_TIMEOUT_MS = 5 * 60 * 1000;
+const SCRIPT_CLEAR_TIMEOUT_MS = 5 * 1000;
+
+const CSS_EXPIRY_TIMEOUT_MS = 30 * 60 * 1000;
+const CSSCODE_EXPIRY_TIMEOUT_MS = 10 * 60 * 1000;
+
+const scriptCaches = new WeakSet();
+const sheetCacheDocuments = new DefaultWeakMap(() => new WeakSet());
+
+class CacheMap extends DefaultMap {
+ constructor(timeout, getter, extension) {
+ super(getter);
+
+ this.expiryTimeout = timeout;
+
+ scriptCaches.add(this);
+
+ // This ensures that all the cached scripts and stylesheets are deleted
+ // from the cache and the xpi is no longer actively used.
+ // See Bug 1435100 for rationale.
+ extension.once("shutdown", () => {
+ this.clear(-1);
+ });
+ }
+
+ get(url) {
+ let promise = super.get(url);
+
+ promise.lastUsed = Date.now();
+ if (promise.timer) {
+ promise.timer.cancel();
+ }
+ promise.timer = Timer(
+ this.delete.bind(this, url),
+ this.expiryTimeout,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+
+ return promise;
+ }
+
+ delete(url) {
+ if (this.has(url)) {
+ super.get(url).timer.cancel();
+ }
+
+ return super.delete(url);
+ }
+
+ clear(timeout = SCRIPT_CLEAR_TIMEOUT_MS) {
+ let now = Date.now();
+ for (let [url, promise] of this.entries()) {
+ // Delete the entry if expired or if clear has been called with timeout -1
+ // (which is used to force the cache to clear all the entries, e.g. when the
+ // extension is shutting down).
+ if (timeout === -1 || now - promise.lastUsed >= timeout) {
+ this.delete(url);
+ }
+ }
+ }
+}
+
+class ScriptCache extends CacheMap {
+ constructor(options, extension) {
+ super(
+ SCRIPT_EXPIRY_TIMEOUT_MS,
+ url => {
+ let promise = ChromeUtils.compileScript(url, options);
+ promise.then(script => {
+ promise.script = script;
+ });
+ return promise;
+ },
+ extension
+ );
+ }
+}
+
+/**
+ * Shared base class for the two specialized CSS caches:
+ * CSSCache (for the "url"-based stylesheets) and CSSCodeCache
+ * (for the stylesheet defined by plain CSS content as a string).
+ */
+class BaseCSSCache extends CacheMap {
+ constructor(expiryTimeout, defaultConstructor, extension) {
+ super(expiryTimeout, defaultConstructor, extension);
+ }
+
+ addDocument(key, document) {
+ sheetCacheDocuments.get(this.get(key)).add(document);
+ }
+
+ deleteDocument(key, document) {
+ sheetCacheDocuments.get(this.get(key)).delete(document);
+ }
+
+ delete(key) {
+ if (this.has(key)) {
+ let promise = this.get(key);
+
+ // Never remove a sheet from the cache if it's still being used by a
+ // document. Rule processors can be shared between documents with the
+ // same preloaded sheet, so we only lose by removing them while they're
+ // still in use.
+ let docs = ChromeUtils.nondeterministicGetWeakSetKeys(
+ sheetCacheDocuments.get(promise)
+ );
+ if (docs.length) {
+ return;
+ }
+ }
+
+ return super.delete(key);
+ }
+}
+
+/**
+ * Cache of the preloaded stylesheet defined by url.
+ */
+class CSSCache extends BaseCSSCache {
+ constructor(sheetType, extension) {
+ super(
+ CSS_EXPIRY_TIMEOUT_MS,
+ url => {
+ let uri = Services.io.newURI(url);
+ return lazy.styleSheetService
+ .preloadSheetAsync(uri, sheetType)
+ .then(sheet => {
+ return { url, sheet };
+ });
+ },
+ extension
+ );
+ }
+}
+
+/**
+ * Cache of the preloaded stylesheet defined by plain CSS content as a string,
+ * the key of the cached stylesheet is the hash of its "CSSCode" string.
+ */
+class CSSCodeCache extends BaseCSSCache {
+ constructor(sheetType, extension) {
+ super(
+ CSSCODE_EXPIRY_TIMEOUT_MS,
+ hash => {
+ if (!this.has(hash)) {
+ // Do not allow the getter to be used to lazily create the cached stylesheet,
+ // the cached CSSCode stylesheet has to be explicitly set.
+ throw new Error(
+ "Unexistent cached cssCode stylesheet: " + Error().stack
+ );
+ }
+
+ return super.get(hash);
+ },
+ extension
+ );
+
+ // Store the preferred sheetType (used to preload the expected stylesheet type in
+ // the addCSSCode method).
+ this.sheetType = sheetType;
+ }
+
+ addCSSCode(hash, cssCode) {
+ if (this.has(hash)) {
+ // This cssCode have been already cached, no need to create it again.
+ return;
+ }
+ // The `webext=style` portion is added metadata to help us distinguish
+ // different kinds of data URL loads that are triggered with the
+ // SystemPrincipal. It shall be removed with bug 1699425.
+ const uri = Services.io.newURI(
+ "data:text/css;extension=style;charset=utf-8," +
+ encodeURIComponent(cssCode)
+ );
+ const value = lazy.styleSheetService
+ .preloadSheetAsync(uri, this.sheetType)
+ .then(sheet => {
+ return { sheet, uri };
+ });
+
+ super.set(hash, value);
+ }
+}
+
+defineLazyGetter(
+ BrowserExtensionContent.prototype,
+ "staticScripts",
+ function () {
+ return new ScriptCache({ hasReturnValue: false }, this);
+ }
+);
+
+defineLazyGetter(
+ BrowserExtensionContent.prototype,
+ "dynamicScripts",
+ function () {
+ return new ScriptCache({ hasReturnValue: true }, this);
+ }
+);
+
+defineLazyGetter(BrowserExtensionContent.prototype, "userCSS", function () {
+ return new CSSCache(Ci.nsIStyleSheetService.USER_SHEET, this);
+});
+
+defineLazyGetter(BrowserExtensionContent.prototype, "authorCSS", function () {
+ return new CSSCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this);
+});
+
+// These two caches are similar to the above but specialized to cache the cssCode
+// using an hash computed from the cssCode string as the key (instead of the generated data
+// URI which can be pretty long for bigger injected cssCode).
+defineLazyGetter(BrowserExtensionContent.prototype, "userCSSCode", function () {
+ return new CSSCodeCache(Ci.nsIStyleSheetService.USER_SHEET, this);
+});
+
+defineLazyGetter(
+ BrowserExtensionContent.prototype,
+ "authorCSSCode",
+ function () {
+ return new CSSCodeCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this);
+ }
+);
+
+// Represents a content script.
+class Script {
+ /**
+ * @param {BrowserExtensionContent} extension
+ * @param {WebExtensionContentScript|object} matcher
+ * An object with a "matchesWindowGlobal" method and content script
+ * execution details. This is usually a plain WebExtensionContentScript
+ * except when the script is run via `tabs.executeScript`. In this
+ * case, the object may have some extra properties:
+ * wantReturnValue, removeCSS, cssOrigin, jsCode
+ */
+ constructor(extension, matcher) {
+ this.scriptType = "content_script";
+ this.extension = extension;
+ this.matcher = matcher;
+
+ this.runAt = this.matcher.runAt;
+ this.js = this.matcher.jsPaths;
+ this.css = this.matcher.cssPaths.slice();
+ this.cssCodeHash = null;
+
+ this.removeCSS = this.matcher.removeCSS;
+ this.cssOrigin = this.matcher.cssOrigin;
+
+ this.cssCache =
+ extension[this.cssOrigin === "user" ? "userCSS" : "authorCSS"];
+ this.cssCodeCache =
+ extension[this.cssOrigin === "user" ? "userCSSCode" : "authorCSSCode"];
+ this.scriptCache =
+ extension[matcher.wantReturnValue ? "dynamicScripts" : "staticScripts"];
+
+ /** @type {WeakSet<Document>} A set of documents injected into. */
+ this.injectedInto = new WeakSet();
+
+ if (matcher.wantReturnValue) {
+ this.compileScripts();
+ this.loadCSS();
+ }
+ }
+
+ get requiresCleanup() {
+ return !this.removeCSS && (!!this.css.length || this.cssCodeHash);
+ }
+
+ async addCSSCode(cssCode) {
+ if (!cssCode) {
+ return;
+ }
+
+ // Store the hash of the cssCode.
+ const buffer = await crypto.subtle.digest(
+ "SHA-1",
+ new TextEncoder().encode(cssCode)
+ );
+ this.cssCodeHash = String.fromCharCode(...new Uint16Array(buffer));
+
+ // Cache and preload the cssCode stylesheet.
+ this.cssCodeCache.addCSSCode(this.cssCodeHash, cssCode);
+ }
+
+ compileScripts() {
+ return this.js.map(url => this.scriptCache.get(url));
+ }
+
+ loadCSS() {
+ return this.css.map(url => this.cssCache.get(url));
+ }
+
+ preload() {
+ this.loadCSS();
+ this.compileScripts();
+ }
+
+ cleanup(window) {
+ if (this.requiresCleanup) {
+ if (window) {
+ let { windowUtils } = window;
+
+ let type =
+ this.cssOrigin === "user"
+ ? windowUtils.USER_SHEET
+ : windowUtils.AUTHOR_SHEET;
+
+ for (let url of this.css) {
+ this.cssCache.deleteDocument(url, window.document);
+
+ if (!window.closed) {
+ runSafeSyncWithoutClone(
+ windowUtils.removeSheetUsingURIString,
+ url,
+ type
+ );
+ }
+ }
+
+ const { cssCodeHash } = this;
+
+ if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) {
+ if (!window.closed) {
+ this.cssCodeCache.get(cssCodeHash).then(({ uri }) => {
+ runSafeSyncWithoutClone(windowUtils.removeSheet, uri, type);
+ });
+ }
+ this.cssCodeCache.deleteDocument(cssCodeHash, window.document);
+ }
+ }
+
+ // Clear any sheets that were kept alive past their timeout as
+ // a result of living in this document.
+ this.cssCodeCache.clear(CSSCODE_EXPIRY_TIMEOUT_MS);
+ this.cssCache.clear(CSS_EXPIRY_TIMEOUT_MS);
+ }
+ }
+
+ matchesWindowGlobal(windowGlobal, ignorePermissions) {
+ return this.matcher.matchesWindowGlobal(windowGlobal, ignorePermissions);
+ }
+
+ async injectInto(window, reportExceptions = true) {
+ if (
+ !lazy.isContentScriptProcess ||
+ this.injectedInto.has(window.document)
+ ) {
+ return;
+ }
+ this.injectedInto.add(window.document);
+
+ let context = this.extension.getContext(window);
+ for (let script of this.matcher.jsPaths) {
+ context.logActivity(this.scriptType, script, {
+ url: window.location.href,
+ });
+ }
+
+ try {
+ if (this.runAt === "document_end") {
+ await promiseDocumentReady(window.document);
+ } else if (this.runAt === "document_idle") {
+ await Promise.race([
+ promiseDocumentIdle(window),
+ promiseDocumentLoaded(window.document),
+ ]);
+ }
+
+ return this.inject(context, reportExceptions);
+ } catch (e) {
+ return Promise.reject(context.normalizeError(e));
+ }
+ }
+
+ /**
+ * Tries to inject this script into the given window and sandbox, if
+ * there are pending operations for the window's current load state.
+ *
+ * @param {ContentScriptContextChild} context
+ * The content script context into which to inject the scripts.
+ * @param {boolean} reportExceptions
+ * Defaults to true and reports any exception directly to the console
+ * and no exception will be thrown out of this function.
+ * @returns {Promise<any>}
+ * Resolves to the last value in the evaluated script, when
+ * execution is complete.
+ */
+ async inject(context, reportExceptions = true) {
+ DocumentManager.lazyInit();
+ if (this.requiresCleanup) {
+ context.addScript(this);
+ }
+
+ const { cssCodeHash } = this;
+
+ let cssPromise;
+ if (this.css.length || cssCodeHash) {
+ let window = context.contentWindow;
+ let { windowUtils } = window;
+
+ let type =
+ this.cssOrigin === "user"
+ ? windowUtils.USER_SHEET
+ : windowUtils.AUTHOR_SHEET;
+
+ if (this.removeCSS) {
+ for (let url of this.css) {
+ this.cssCache.deleteDocument(url, window.document);
+
+ runSafeSyncWithoutClone(
+ windowUtils.removeSheetUsingURIString,
+ url,
+ type
+ );
+ }
+
+ if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) {
+ const { uri } = await this.cssCodeCache.get(cssCodeHash);
+ this.cssCodeCache.deleteDocument(cssCodeHash, window.document);
+
+ runSafeSyncWithoutClone(windowUtils.removeSheet, uri, type);
+ }
+ } else {
+ cssPromise = Promise.all(this.loadCSS()).then(sheets => {
+ let window = context.contentWindow;
+ if (!window) {
+ return;
+ }
+
+ for (let { url, sheet } of sheets) {
+ this.cssCache.addDocument(url, window.document);
+
+ runSafeSyncWithoutClone(windowUtils.addSheet, sheet, type);
+ }
+ });
+
+ if (cssCodeHash) {
+ cssPromise = cssPromise.then(async () => {
+ const { sheet } = await this.cssCodeCache.get(cssCodeHash);
+ this.cssCodeCache.addDocument(cssCodeHash, window.document);
+
+ runSafeSyncWithoutClone(windowUtils.addSheet, sheet, type);
+ });
+ }
+
+ // We're loading stylesheets via the stylesheet service, which means
+ // that the normal mechanism for blocking layout and onload for pending
+ // stylesheets aren't in effect (since there's no document to block). So
+ // we need to do something custom here, similar to what we do for
+ // scripts. Blocking parsing is overkill, since we really just want to
+ // block layout and onload. But we have an API to do the former and not
+ // the latter, so we do it that way. This hopefully isn't a performance
+ // problem since there are no network loads involved, and since we cache
+ // the stylesheets on first load. We should fix this up if it does becomes
+ // a problem.
+ if (this.css.length) {
+ context.contentWindow.document.blockParsing(cssPromise, {
+ blockScriptCreated: false,
+ });
+ }
+ }
+ }
+
+ let scripts = this.getCompiledScripts(context);
+ if (scripts instanceof Promise) {
+ scripts = await scripts;
+ }
+
+ // Make sure we've injected any related CSS before we run content scripts.
+ await cssPromise;
+
+ let result;
+
+ const { extension } = context;
+
+ // The evaluations below may throw, in which case the promise will be
+ // automatically rejected.
+ lazy.ExtensionTelemetry.contentScriptInjection.stopwatchStart(
+ extension,
+ context
+ );
+ try {
+ for (let script of scripts) {
+ result = script.executeInGlobal(context.cloneScope, {
+ reportExceptions,
+ });
+ }
+
+ if (this.matcher.jsCode) {
+ result = Cu.evalInSandbox(
+ this.matcher.jsCode,
+ context.cloneScope,
+ "latest",
+ "sandbox eval code",
+ 1
+ );
+ }
+ } finally {
+ lazy.ExtensionTelemetry.contentScriptInjection.stopwatchFinish(
+ extension,
+ context
+ );
+ }
+
+ return result;
+ }
+
+ /**
+ * Get the compiled scripts (if they are already precompiled and cached) or a promise which resolves
+ * to the precompiled scripts (once they have been compiled and cached).
+ *
+ * @param {ContentScriptContextChild} context
+ * The document to block the parsing on, if the scripts are not yet precompiled and cached.
+ *
+ * @returns {Array<PreloadedScript> | Promise<Array<PreloadedScript>>}
+ * Returns an array of preloaded scripts if they are already available, or a promise which
+ * resolves to the array of the preloaded scripts once they are precompiled and cached.
+ */
+ getCompiledScripts(context) {
+ let scriptPromises = this.compileScripts();
+ let scripts = scriptPromises.map(promise => promise.script);
+
+ // If not all scripts are already available in the cache, block
+ // parsing and wait all promises to resolve.
+ if (!scripts.every(script => script)) {
+ let promise = Promise.all(scriptPromises);
+
+ // If there is any syntax error, the script promises will be rejected.
+ //
+ // Notify the exception directly to the console so that it can
+ // be displayed in the web console by flagging the error with the right
+ // innerWindowID.
+ for (const p of scriptPromises) {
+ p.catch(error => {
+ Services.console.logMessage(
+ new ScriptError(
+ `${error.name}: ${error.message}`,
+ error.fileName,
+ null,
+ error.lineNumber,
+ error.columnNumber,
+ Ci.nsIScriptError.errorFlag,
+ "content javascript",
+ context.innerWindowID
+ )
+ );
+ });
+ }
+
+ // If we're supposed to inject at the start of the document load,
+ // and we haven't already missed that point, block further parsing
+ // until the scripts have been loaded.
+ const { document } = context.contentWindow;
+ if (
+ this.runAt === "document_start" &&
+ document.readyState !== "complete"
+ ) {
+ document.blockParsing(promise, { blockScriptCreated: false });
+ }
+
+ return promise;
+ }
+
+ return scripts;
+ }
+}
+
+// Represents a user script.
+class UserScript extends Script {
+ /**
+ * @param {BrowserExtensionContent} extension
+ * @param {WebExtensionContentScript|object} matcher
+ * An object with a "matchesWindowGlobal" method and content script
+ * execution details.
+ */
+ constructor(extension, matcher) {
+ super(extension, matcher);
+ this.scriptType = "user_script";
+
+ // This is an opaque object that the extension provides, it is associated to
+ // the particular userScript and it is passed as a parameter to the custom
+ // userScripts APIs defined by the extension.
+ this.scriptMetadata = matcher.userScriptOptions.scriptMetadata;
+ this.apiScriptURL =
+ extension.manifest.user_scripts &&
+ extension.manifest.user_scripts.api_script;
+
+ // Add the apiScript to the js scripts to compile.
+ if (this.apiScriptURL) {
+ this.js = [this.apiScriptURL].concat(this.js);
+ }
+
+ // WeakMap<ContentScriptContextChild, Sandbox>
+ this.sandboxes = new DefaultWeakMap(context => {
+ return this.createSandbox(context);
+ });
+ }
+
+ async inject(context) {
+ DocumentManager.lazyInit();
+
+ let scripts = this.getCompiledScripts(context);
+ if (scripts instanceof Promise) {
+ scripts = await scripts;
+ }
+
+ let apiScript, sandboxScripts;
+
+ if (this.apiScriptURL) {
+ [apiScript, ...sandboxScripts] = scripts;
+ } else {
+ sandboxScripts = scripts;
+ }
+
+ // Load and execute the API script once per context.
+ if (apiScript) {
+ context.executeAPIScript(apiScript);
+ }
+
+ let userScriptSandbox = this.sandboxes.get(context);
+
+ context.callOnClose({
+ close: () => {
+ // Destroy the userScript sandbox when the related ContentScriptContextChild instance
+ // is being closed.
+ this.sandboxes.delete(context);
+ Cu.nukeSandbox(userScriptSandbox);
+ },
+ });
+
+ // Notify listeners subscribed to the userScripts.onBeforeScript API event,
+ // to allow extension API script to provide its custom APIs to the userScript.
+ if (apiScript) {
+ context.userScriptsEvents.emit(
+ "on-before-script",
+ this.scriptMetadata,
+ userScriptSandbox
+ );
+ }
+
+ for (let script of sandboxScripts) {
+ script.executeInGlobal(userScriptSandbox);
+ }
+ }
+
+ createSandbox(context) {
+ const { contentWindow } = context;
+ const contentPrincipal = contentWindow.document.nodePrincipal;
+ const ssm = Services.scriptSecurityManager;
+
+ let principal;
+ if (contentPrincipal.isSystemPrincipal) {
+ principal = ssm.createNullPrincipal(contentPrincipal.originAttributes);
+ } else {
+ principal = [contentPrincipal];
+ }
+
+ const sandbox = Cu.Sandbox(principal, {
+ sandboxName: `User Script registered by ${this.extension.policy.debugName}`,
+ sandboxPrototype: contentWindow,
+ sameZoneAs: contentWindow,
+ wantXrays: true,
+ wantGlobalProperties: ["XMLHttpRequest", "fetch", "WebSocket"],
+ originAttributes: contentPrincipal.originAttributes,
+ metadata: {
+ "inner-window-id": context.innerWindowID,
+ addonId: this.extension.policy.id,
+ },
+ });
+
+ return sandbox;
+ }
+}
+
+var contentScripts = new DefaultWeakMap(matcher => {
+ const extension = lazy.ExtensionProcessScript.extensions.get(
+ matcher.extension
+ );
+
+ if ("userScriptOptions" in matcher) {
+ return new UserScript(extension, matcher);
+ }
+
+ return new Script(extension, matcher);
+});
+
+/**
+ * An execution context for semi-privileged extension content scripts.
+ *
+ * This is the child side of the ContentScriptContextParent class
+ * defined in ExtensionParent.jsm.
+ */
+class ContentScriptContextChild extends BaseContext {
+ constructor(extension, contentWindow) {
+ super("content_child", extension);
+
+ this.setContentWindow(contentWindow);
+
+ let frameId = lazy.WebNavigationFrames.getFrameId(contentWindow);
+ this.frameId = frameId;
+
+ this.browsingContextId = contentWindow.docShell.browsingContext.id;
+
+ this.scripts = [];
+
+ let contentPrincipal = contentWindow.document.nodePrincipal;
+ let ssm = Services.scriptSecurityManager;
+
+ // Copy origin attributes from the content window origin attributes to
+ // preserve the user context id.
+ let attrs = contentPrincipal.originAttributes;
+ let extensionPrincipal = ssm.createContentPrincipal(
+ this.extension.baseURI,
+ attrs
+ );
+
+ this.isExtensionPage = contentPrincipal.equals(extensionPrincipal);
+
+ if (this.isExtensionPage) {
+ // This is an iframe with content script API enabled and its principal
+ // should be the contentWindow itself. We create a sandbox with the
+ // contentWindow as principal and with X-rays disabled because it
+ // enables us to create the APIs object in this sandbox object and then
+ // copying it into the iframe's window. See bug 1214658.
+ this.sandbox = Cu.Sandbox(contentWindow, {
+ sandboxName: `Web-Accessible Extension Page ${extension.policy.debugName}`,
+ sandboxPrototype: contentWindow,
+ sameZoneAs: contentWindow,
+ wantXrays: false,
+ isWebExtensionContentScript: true,
+ });
+ } else {
+ let principal;
+ if (contentPrincipal.isSystemPrincipal) {
+ // Make sure we don't hand out the system principal by accident.
+ // Also make sure that the null principal has the right origin attributes.
+ principal = ssm.createNullPrincipal(attrs);
+ } else {
+ principal = [contentPrincipal, extensionPrincipal];
+ }
+ // This metadata is required by the Developer Tools, in order for
+ // the content script to be associated with both the extension and
+ // the tab holding the content page.
+ let metadata = {
+ "inner-window-id": this.innerWindowID,
+ addonId: extensionPrincipal.addonId,
+ };
+
+ let isMV2 = extension.manifestVersion == 2;
+ let wantGlobalProperties;
+ if (isMV2) {
+ // In MV2, fetch/XHR support cross-origin requests.
+ // WebSocket was also included to avoid CSP effects (bug 1676024).
+ wantGlobalProperties = ["XMLHttpRequest", "fetch", "WebSocket"];
+ } else {
+ // In MV3, fetch/XHR have the same capabilities as the web page.
+ wantGlobalProperties = [];
+ }
+ this.sandbox = Cu.Sandbox(principal, {
+ metadata,
+ sandboxName: `Content Script ${extension.policy.debugName}`,
+ sandboxPrototype: contentWindow,
+ sameZoneAs: contentWindow,
+ wantXrays: true,
+ isWebExtensionContentScript: true,
+ wantExportHelpers: true,
+ wantGlobalProperties,
+ originAttributes: attrs,
+ });
+
+ // Preserve a copy of the original Error and Promise globals from the sandbox object,
+ // which are used in the WebExtensions internals (before any content script code had
+ // any chance to redefine them).
+ this.cloneScopePromise = this.sandbox.Promise;
+ this.cloneScopeError = this.sandbox.Error;
+
+ if (isMV2) {
+ // Preserve a copy of the original window's XMLHttpRequest and fetch
+ // in a content object (fetch is manually binded to the window
+ // to prevent it from raising a TypeError because content object is not
+ // a real window).
+ Cu.evalInSandbox(
+ `
+ this.content = {
+ XMLHttpRequest: window.XMLHttpRequest,
+ fetch: window.fetch.bind(window),
+ WebSocket: window.WebSocket,
+ };
+
+ window.JSON = JSON;
+ window.XMLHttpRequest = XMLHttpRequest;
+ window.fetch = fetch;
+ window.WebSocket = WebSocket;
+ `,
+ this.sandbox
+ );
+ } else {
+ // The sandbox's JSON API can deal with values from the sandbox and the
+ // contentWindow, but window.JSON cannot (and it could potentially be
+ // spoofed by the web page). jQuery.parseJSON relies on window.JSON.
+ Cu.evalInSandbox("window.JSON = JSON;", this.sandbox);
+ }
+ }
+
+ Object.defineProperty(this, "principal", {
+ value: Cu.getObjectPrincipal(this.sandbox),
+ enumerable: true,
+ configurable: true,
+ });
+
+ this.url = contentWindow.location.href;
+
+ lazy.Schemas.exportLazyGetter(
+ this.sandbox,
+ "browser",
+ () => this.chromeObj
+ );
+ lazy.Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj);
+
+ // Keep track if the userScript API script has been already executed in this context
+ // (e.g. because there are more then one UserScripts that match the related webpage
+ // and so the UserScript apiScript has already been executed).
+ this.hasUserScriptAPIs = false;
+
+ // A lazy created EventEmitter related to userScripts-specific events.
+ defineLazyGetter(this, "userScriptsEvents", () => {
+ return new ExtensionCommon.EventEmitter();
+ });
+ }
+
+ injectAPI() {
+ if (!this.isExtensionPage) {
+ throw new Error("Cannot inject extension API into non-extension window");
+ }
+
+ // This is an iframe with content script API enabled (See Bug 1214658)
+ lazy.Schemas.exportLazyGetter(
+ this.contentWindow,
+ "browser",
+ () => this.chromeObj
+ );
+ lazy.Schemas.exportLazyGetter(
+ this.contentWindow,
+ "chrome",
+ () => this.chromeObj
+ );
+ }
+
+ async logActivity(type, name, data) {
+ ExtensionActivityLogChild.log(this, type, name, data);
+ }
+
+ get cloneScope() {
+ return this.sandbox;
+ }
+
+ async executeAPIScript(apiScript) {
+ // Execute the UserScript apiScript only once per context (e.g. more then one UserScripts
+ // match the same webpage and the apiScript has already been executed).
+ if (apiScript && !this.hasUserScriptAPIs) {
+ this.hasUserScriptAPIs = true;
+ apiScript.executeInGlobal(this.cloneScope);
+ }
+ }
+
+ addScript(script) {
+ if (script.requiresCleanup) {
+ this.scripts.push(script);
+ }
+ }
+
+ close() {
+ super.unload();
+
+ // Cleanup the scripts even if the contentWindow have been destroyed.
+ for (let script of this.scripts) {
+ script.cleanup(this.contentWindow);
+ }
+
+ if (this.contentWindow) {
+ // Overwrite the content script APIs with an empty object if the APIs objects are still
+ // defined in the content window (See Bug 1214658).
+ if (this.isExtensionPage) {
+ Cu.createObjectIn(this.contentWindow, { defineAs: "browser" });
+ Cu.createObjectIn(this.contentWindow, { defineAs: "chrome" });
+ }
+ }
+ Cu.nukeSandbox(this.sandbox);
+
+ this.sandbox = null;
+ }
+
+ get childManager() {
+ apiManager.lazyInit();
+ let can = new CanOfAPIs(this, apiManager, {});
+ let childManager = new ChildAPIManager(this, this.messageManager, can, {
+ envType: "content_parent",
+ url: this.url,
+ });
+ this.callOnClose(childManager);
+ return redefineGetter(this, "childManager", childManager);
+ }
+
+ get chromeObj() {
+ let chromeObj = Cu.createObjectIn(this.sandbox);
+ this.childManager.inject(chromeObj);
+ return redefineGetter(this, "chromeObj", chromeObj);
+ }
+
+ get messenger() {
+ return redefineGetter(this, "messenger", new Messenger(this));
+ }
+}
+
+// Responsible for creating ExtensionContexts and injecting content
+// scripts into them when new documents are created.
+DocumentManager = {
+ // Map[windowId -> Map[ExtensionChild -> ContentScriptContextChild]]
+ contexts: new Map(),
+
+ initialized: false,
+
+ lazyInit() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ Services.obs.addObserver(this, "inner-window-destroyed");
+ Services.obs.addObserver(this, "memory-pressure");
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "inner-window-destroyed");
+ Services.obs.removeObserver(this, "memory-pressure");
+ },
+
+ observers: {
+ "inner-window-destroyed"(subject, topic, data) {
+ let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
+
+ // Close any existent content-script context for the destroyed window.
+ if (this.contexts.has(windowId)) {
+ let extensions = this.contexts.get(windowId);
+ for (let context of extensions.values()) {
+ context.close();
+ }
+
+ this.contexts.delete(windowId);
+ }
+ },
+ "memory-pressure"(subject, topic, data) {
+ let timeout = data === "heap-minimize" ? 0 : undefined;
+
+ for (let cache of ChromeUtils.nondeterministicGetWeakSetKeys(
+ scriptCaches
+ )) {
+ cache.clear(timeout);
+ }
+ },
+ },
+
+ /**
+ * @param {object} subject
+ * @param {keyof typeof DocumentManager.observers} topic
+ * @param {any} data
+ */
+ observe(subject, topic, data) {
+ this.observers[topic].call(this, subject, topic, data);
+ },
+
+ shutdownExtension(extension) {
+ for (let extensions of this.contexts.values()) {
+ let context = extensions.get(extension);
+ if (context) {
+ context.close();
+ extensions.delete(extension);
+ }
+ }
+ },
+
+ getContexts(window) {
+ let winId = getInnerWindowID(window);
+
+ let extensions = this.contexts.get(winId);
+ if (!extensions) {
+ extensions = new Map();
+ this.contexts.set(winId, extensions);
+ }
+
+ return extensions;
+ },
+
+ // For test use only.
+ getContext(extensionId, window) {
+ for (let [extension, context] of this.getContexts(window)) {
+ if (extension.id === extensionId) {
+ return context;
+ }
+ }
+ },
+
+ getContentScriptGlobals(window) {
+ let extensions = this.contexts.get(getInnerWindowID(window));
+
+ if (extensions) {
+ return Array.from(extensions.values(), ctx => ctx.sandbox);
+ }
+
+ return [];
+ },
+
+ initExtensionContext(extension, window) {
+ extension.getContext(window).injectAPI();
+ },
+};
+
+export var ExtensionContent = {
+ BrowserExtensionContent,
+
+ contentScripts,
+
+ shutdownExtension(extension) {
+ DocumentManager.shutdownExtension(extension);
+ },
+
+ // This helper is exported to be integrated in the devtools RDP actors,
+ // that can use it to retrieve the existent WebExtensions ContentScripts
+ // of a target window and be able to show the ContentScripts source in the
+ // DevTools Debugger panel.
+ getContentScriptGlobals(window) {
+ return DocumentManager.getContentScriptGlobals(window);
+ },
+
+ initExtensionContext(extension, window) {
+ DocumentManager.initExtensionContext(extension, window);
+ },
+
+ getContext(extension, window) {
+ let extensions = DocumentManager.getContexts(window);
+
+ let context = extensions.get(extension);
+ if (!context) {
+ context = new ContentScriptContextChild(extension, window);
+ extensions.set(extension, context);
+ }
+ return context;
+ },
+
+ // For test use only.
+ getContextByExtensionId(extensionId, window) {
+ return DocumentManager.getContext(extensionId, window);
+ },
+
+ async handleDetectLanguage({ windows }) {
+ let wgc = WindowGlobalChild.getByInnerWindowId(windows[0]);
+ let doc = wgc.browsingContext.window.document;
+ await promiseDocumentReady(doc);
+
+ // The CLD2 library can analyze HTML, but that uses more memory, and
+ // emscripten can't shrink its heap, so we use plain text instead.
+ let encoder = Cu.createDocumentEncoder("text/plain");
+ encoder.init(doc, "text/plain", Ci.nsIDocumentEncoder.SkipInvisibleContent);
+
+ let result = await lazy.LanguageDetector.detectLanguage({
+ language:
+ doc.documentElement.getAttribute("xml:lang") ||
+ doc.documentElement.getAttribute("lang") ||
+ doc.contentLanguage ||
+ null,
+ tld: doc.location.hostname.match(/[a-z]*$/)[0],
+ text: encoder.encodeToStringWithMaxLength(60 * 1024),
+ encoding: doc.characterSet,
+ });
+ return result.language === "un" ? "und" : result.language;
+ },
+
+ // Activate MV3 content scripts in all same-origin frames for this tab.
+ handleActivateScripts({ options, windows }) {
+ let policy = WebExtensionPolicy.getByID(options.id);
+
+ // Order content scripts by run_at timing.
+ let runAt = { document_start: [], document_end: [], document_idle: [] };
+ for (let matcher of policy.contentScripts) {
+ runAt[matcher.runAt].push(this.contentScripts.get(matcher));
+ }
+
+ // If we got here, checks in TabManagerBase.activateScripts assert:
+ // 1) this is a MV3 extension, with Origin Controls,
+ // 2) with a host permission (or content script) for the tab's top origin,
+ // 3) and that host permission hasn't been granted yet.
+
+ // We treat the action click as implicit user's choice to activate the
+ // extension on the current site, so we can safely run (matching) content
+ // scripts in all sameOriginWithTop frames while ignoring host permission.
+
+ let { browsingContext } = WindowGlobalChild.getByInnerWindowId(windows[0]);
+ for (let bc of browsingContext.getAllBrowsingContextsInSubtree()) {
+ let wgc = bc.currentWindowContext.windowGlobalChild;
+ if (wgc?.sameOriginWithTop) {
+ // This is TOCTOU safe: if a frame navigated after same-origin check,
+ // wgc.isClosed would be true and .matchesWindowGlobal() would fail.
+ const runScript = cs => {
+ if (cs.matchesWindowGlobal(wgc, /* ignorePermissions */ true)) {
+ return cs.injectInto(bc.window);
+ }
+ };
+
+ // Inject all matching content scripts in proper run_at order.
+ Promise.all(runAt.document_start.map(runScript))
+ .then(() => Promise.all(runAt.document_end.map(runScript)))
+ .then(() => Promise.all(runAt.document_idle.map(runScript)));
+ }
+ }
+ },
+
+ // Used to executeScript, insertCSS and removeCSS.
+ async handleActorExecute({ options, windows }) {
+ let policy = WebExtensionPolicy.getByID(options.extensionId);
+ // `WebExtensionContentScript` uses `MozDocumentMatcher::Matches` to ensure
+ // that a script can be run in a document. That requires either `frameId`
+ // or `allFrames` to be set. When `frameIds` (plural) is used, we force
+ // `allFrames` to be `true` in order to match any frame. This is OK because
+ // `executeInWin()` below looks up the window for the given `frameIds`
+ // immediately before `script.injectInto()`. Due to this, we won't run
+ // scripts in windows with non-matching `frameId`, despite `allFrames`
+ // being set to `true`.
+ if (options.frameIds) {
+ options.allFrames = true;
+ }
+ let matcher = new WebExtensionContentScript(policy, options);
+
+ Object.assign(matcher, {
+ wantReturnValue: options.wantReturnValue,
+ removeCSS: options.removeCSS,
+ cssOrigin: options.cssOrigin,
+ jsCode: options.jsCode,
+ });
+ let script = contentScripts.get(matcher);
+
+ // Add the cssCode to the script, so that it can be converted into a cached URL.
+ await script.addCSSCode(options.cssCode);
+ delete options.cssCode;
+
+ const executeInWin = innerId => {
+ let wg = WindowGlobalChild.getByInnerWindowId(innerId);
+ if (wg?.isCurrentGlobal && script.matchesWindowGlobal(wg)) {
+ let bc = wg.browsingContext;
+
+ return {
+ frameId: bc.parent ? bc.id : 0,
+ // Disable exception reporting directly to the console
+ // in order to pass the exceptions back to the callsite.
+ promise: script.injectInto(bc.window, false),
+ };
+ }
+ };
+
+ let promisesWithFrameIds = windows.map(executeInWin).filter(obj => obj);
+
+ let result = await Promise.all(
+ promisesWithFrameIds.map(async ({ frameId, promise }) => {
+ if (!options.returnResultsWithFrameIds) {
+ return promise;
+ }
+
+ try {
+ const result = await promise;
+
+ return { frameId, result };
+ } catch (error) {
+ return { frameId, error };
+ }
+ })
+ ).catch(
+ // This is useful when we do not return results/errors with frame IDs in
+ // the promises above.
+ e => Promise.reject({ message: e.message })
+ );
+
+ try {
+ // Check if the result can be structured-cloned before sending back.
+ return Cu.cloneInto(result, this);
+ } catch (e) {
+ let path = options.jsPaths.slice(-1)[0] ?? "<anonymous code>";
+ let message = `Script '${path}' result is non-structured-clonable data`;
+ return Promise.reject({ message, fileName: path });
+ }
+ },
+};
+
+/**
+ * Child side of the ExtensionContent process actor, handles some tabs.* APIs.
+ */
+export class ExtensionContentChild extends JSProcessActorChild {
+ receiveMessage({ name, data }) {
+ if (!lazy.isContentScriptProcess) {
+ return;
+ }
+ switch (name) {
+ case "DetectLanguage":
+ return ExtensionContent.handleDetectLanguage(data);
+ case "Execute":
+ return ExtensionContent.handleActorExecute(data);
+ case "ActivateScripts":
+ return ExtensionContent.handleActivateScripts(data);
+ }
+ }
+}