1536 lines
50 KiB
JavaScript
1536 lines
50 KiB
JavaScript
/* -*- 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/. */
|
|
/* eslint-disable mozilla/valid-lazy */
|
|
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
|
|
|
|
const lazy = XPCOMUtils.declareLazy({
|
|
ExtensionProcessScript:
|
|
"resource://gre/modules/ExtensionProcessScript.sys.mjs",
|
|
ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
|
|
ExtensionUserScriptsContent:
|
|
"resource://gre/modules/ExtensionUserScriptsContent.sys.mjs",
|
|
LanguageDetector:
|
|
"resource://gre/modules/translations/LanguageDetector.sys.mjs",
|
|
Schemas: "resource://gre/modules/Schemas.sys.mjs",
|
|
WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
|
|
|
|
styleSheetService: {
|
|
service: "@mozilla.org/content/style-sheet-service;1",
|
|
iid: Ci.nsIStyleSheetService,
|
|
},
|
|
isContentScriptProcess: () =>
|
|
Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT ||
|
|
!WebExtensionPolicy.useRemoteWebExtensions ||
|
|
// Thunderbird still loads some content in the parent process.
|
|
AppConstants.MOZ_APP_NAME == "thunderbird",
|
|
orderedContentScripts: {
|
|
pref: "extensions.webextensions.content_scripts.ordered",
|
|
default: true,
|
|
},
|
|
});
|
|
|
|
const Timer = Components.Constructor(
|
|
"@mozilla.org/timer;1",
|
|
"nsITimer",
|
|
"initWithCallback"
|
|
);
|
|
|
|
const ScriptError = Components.Constructor(
|
|
"@mozilla.org/scripterror;1",
|
|
"nsIScriptError",
|
|
"initWithWindowID"
|
|
);
|
|
|
|
import {
|
|
ChildAPIManager,
|
|
ExtensionChild,
|
|
ExtensionActivityLogChild,
|
|
Messenger,
|
|
} 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;
|
|
|
|
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;
|
|
|
|
// DocumentManager clears scriptCaches early under memory pressure. For
|
|
// this to work, DocumentManager.lazyInit() should be called. In practice,
|
|
// ScriptCache/CSSCache/CSSCodeCache are only instantiated and populated
|
|
// when a content script/style is to be injected. This always depends on a
|
|
// ContentScriptContextChild instance, which is always paired with a call
|
|
// to DocumentManager.lazyInit().
|
|
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 => {
|
|
/** @type {Promise<PrecompiledScript> & { script?: PrecompiledScript }} */
|
|
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);
|
|
}
|
|
|
|
delete(key) {
|
|
if (this.has(key)) {
|
|
let sheetPromise = 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(sheetPromise)
|
|
);
|
|
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);
|
|
const sheetPromise = lazy.styleSheetService.preloadSheetAsync(
|
|
uri,
|
|
sheetType
|
|
);
|
|
sheetPromise.then(sheet => {
|
|
sheetPromise.sheet = sheet;
|
|
});
|
|
return sheetPromise;
|
|
},
|
|
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 sheetPromise = lazy.styleSheetService.preloadSheetAsync(
|
|
uri,
|
|
this.sheetType
|
|
);
|
|
sheetPromise.then(sheet => {
|
|
sheetPromise.sheet = sheet;
|
|
});
|
|
// styleURI: windowUtils.removeSheet requires a URI to identify the sheet.
|
|
sheetPromise.styleURI = uri;
|
|
|
|
super.set(hash, sheetPromise);
|
|
}
|
|
}
|
|
|
|
defineLazyGetter(ExtensionChild.prototype, "staticScripts", function () {
|
|
return new ScriptCache({ hasReturnValue: false }, this);
|
|
});
|
|
|
|
defineLazyGetter(ExtensionChild.prototype, "dynamicScripts", function () {
|
|
return new ScriptCache({ hasReturnValue: true }, this);
|
|
});
|
|
|
|
defineLazyGetter(ExtensionChild.prototype, "anonStaticScripts", function () {
|
|
// TODO bug 1651557: Use dynamic name to improve debugger experience.
|
|
const filename = "<anonymous code>";
|
|
return new ScriptCache({ filename, hasReturnValue: false }, this);
|
|
});
|
|
|
|
defineLazyGetter(ExtensionChild.prototype, "anonDynamicScripts", function () {
|
|
// TODO bug 1651557: Use dynamic name to improve debugger experience.
|
|
const filename = "<anonymous code>";
|
|
return new ScriptCache({ filename, hasReturnValue: true }, this);
|
|
});
|
|
|
|
defineLazyGetter(ExtensionChild.prototype, "userCSS", function () {
|
|
return new CSSCache(Ci.nsIStyleSheetService.USER_SHEET, this);
|
|
});
|
|
|
|
defineLazyGetter(ExtensionChild.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(ExtensionChild.prototype, "userCSSCode", function () {
|
|
return new CSSCodeCache(Ci.nsIStyleSheetService.USER_SHEET, this);
|
|
});
|
|
|
|
defineLazyGetter(ExtensionChild.prototype, "authorCSSCode", function () {
|
|
return new CSSCodeCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this);
|
|
});
|
|
|
|
/**
|
|
* This is still an ExtensionChild, but with the properties added above.
|
|
* Unfortunately we can't express that using just JSDocs types locally,
|
|
* so this needs to be used with `& ExtensionChild` explicitly below.
|
|
*
|
|
* @typedef {object} ExtensionChildContent
|
|
* @property {ScriptCache} staticScripts
|
|
* @property {ScriptCache} dynamicScripts
|
|
* @property {ScriptCache} anonStaticScripts
|
|
* @property {ScriptCache} anonDynamicScripts
|
|
* @property {CSSCache} userCSS
|
|
* @property {CSSCache} authorCSS
|
|
* @property {CSSCodeCache} userCSSCode
|
|
* @property {CSSCodeCache} authorCSSCode
|
|
*/
|
|
|
|
/**
|
|
* Script/style injections depend on compiled scripts/styles. If the previously
|
|
* compiled script or style is not found, we block that and later script/style
|
|
* executions until compilation finishes. This is achieved by storing a Promise
|
|
* for that compilation in this gPendingScriptBlockers, for a given context.
|
|
*
|
|
* @type {WeakMap<ContentScriptContextChild, Promise>}
|
|
*/
|
|
const gPendingScriptBlockers = new WeakMap();
|
|
|
|
// Represents a content script.
|
|
class Script {
|
|
/**
|
|
* @param {ExtensionChild & ExtensionChildContent} 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` or
|
|
* `scripting.executeScript`. In this case, the object may have some
|
|
* extra properties: wantReturnValue, removeCSS, cssOrigin
|
|
*/
|
|
constructor(extension, matcher) {
|
|
this.scriptType = "content_script";
|
|
this.extension = extension;
|
|
this.matcher = matcher;
|
|
|
|
this.runAt = this.matcher.runAt;
|
|
this.world = this.matcher.world;
|
|
this.js = this.matcher.jsPaths;
|
|
this.jsCode = null; // tabs/scripting.executeScript + ISOLATED world.
|
|
this.jsCodeCompiledScript = null; // scripting.executeScript + MAIN world.
|
|
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"];
|
|
if (this.world === "MAIN") {
|
|
this.scriptCache = matcher.wantReturnValue
|
|
? extension.anonDynamicScripts
|
|
: extension.anonStaticScripts;
|
|
} else {
|
|
this.scriptCache = matcher.wantReturnValue
|
|
? extension.dynamicScripts
|
|
: extension.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);
|
|
}
|
|
|
|
addJSCode(jsCode) {
|
|
if (!jsCode) {
|
|
return;
|
|
}
|
|
if (this.world === "MAIN") {
|
|
// To support the scripting.executeScript API, we would like to execute a
|
|
// string in the context of the web page in #injectIntoMainWorld().
|
|
// To do so without being blocked by the web page's CSP, we convert
|
|
// jsCode to a PrecompiledScript, which is then executed by the logic
|
|
// that is usually used for file-based execution.
|
|
const dataUrl = `data:text/javascript,${encodeURIComponent(jsCode)}`;
|
|
const options = {
|
|
hasReturnValue: this.matcher.wantReturnValue,
|
|
// Redact the file name to hide actual script content from web pages.
|
|
// TODO bug 1651557: Use dynamic name to improve debugger experience.
|
|
filename: "<anonymous code>",
|
|
};
|
|
// Note: this logic is similar to this.scriptCaches.get(...), but we are
|
|
// not using scriptCaches because we don't want the URL to be cached.
|
|
/** @type {Promise<PrecompiledScript> & {script?: PrecompiledScript}} */
|
|
let promised = ChromeUtils.compileScript(dataUrl, options);
|
|
promised.then(script => {
|
|
promised.script = script;
|
|
});
|
|
this.jsCodeCompiledScript = promised;
|
|
} else {
|
|
// this.world === "ISOLATED".
|
|
this.jsCode = jsCode;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
this.removeStyleSheets(window);
|
|
}
|
|
|
|
// 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 {
|
|
// In case of initial about:blank documents, inject immediately without
|
|
// awaiting the runAt logic in the blocks below, to avoid getting stuck
|
|
// due to https://bugzilla.mozilla.org/show_bug.cgi?id=1900222#c7
|
|
// This is only relevant for dynamic code execution because declarative
|
|
// content scripts do not run on initial about:blank - bug 1415539).
|
|
if (!window.document.isInitialDocument) {
|
|
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) {
|
|
// NOTE: Avoid unnecessary use of "await" in this function, because doing
|
|
// so can delay script execution beyond the scheduled point. In particular,
|
|
// document_start scripts should run "immediately" in most cases.
|
|
|
|
if (this.requiresCleanup) {
|
|
context.addScript(this);
|
|
}
|
|
|
|
// To avoid another await (which affects timing) or .then() chaining
|
|
// (which would create a new Promise that could duplicate a rejection),
|
|
// we store the index where we expect the result of a Promise.all() call.
|
|
let scriptsIndex, sheetsIndex;
|
|
let sheets = this.getCompiledStyleSheets(context.contentWindow);
|
|
let scripts = this.getCompiledScripts(context);
|
|
|
|
let executionBlockingPromises = [];
|
|
if (gPendingScriptBlockers.has(context) && lazy.orderedContentScripts) {
|
|
executionBlockingPromises.push(gPendingScriptBlockers.get(context));
|
|
}
|
|
if (scripts instanceof Promise) {
|
|
scriptsIndex = executionBlockingPromises.length;
|
|
executionBlockingPromises.push(scripts);
|
|
}
|
|
if (sheets instanceof Promise) {
|
|
sheetsIndex = executionBlockingPromises.length;
|
|
executionBlockingPromises.push(sheets);
|
|
}
|
|
|
|
if (executionBlockingPromises.length) {
|
|
let promise = Promise.all(executionBlockingPromises);
|
|
|
|
// 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/styles have been loaded.
|
|
// This maximizes the chance of content scripts executing before other
|
|
// scripts in the web page.
|
|
//
|
|
// Blocking the full parser is overkill if we are only awaiting style
|
|
// compilation, since we only need to block the parts that are dependent
|
|
// on CSS (layout, onload event, CSSOM, etc). But we have an API to do
|
|
// the former and not atter, 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.
|
|
const { document } = context.contentWindow;
|
|
if (
|
|
this.runAt === "document_start" &&
|
|
document.readyState !== "complete"
|
|
) {
|
|
document.blockParsing(promise, { blockScriptCreated: false });
|
|
}
|
|
|
|
// Store a promise that never rejects, so that failure to compile scripts
|
|
// or styles here does not prevent the scheduling of others.
|
|
let promiseSettled = promise.then(
|
|
() => {},
|
|
() => {}
|
|
);
|
|
gPendingScriptBlockers.set(context, promiseSettled);
|
|
|
|
// Note: in theory, the following async await could result in script
|
|
// execution being scheduled too late. That would be an issue for
|
|
// document_start scripts. In practice, this is not a problem because the
|
|
// compiled script is cached in the process, and preloading to compile
|
|
// starts as soon as the network request for the document has been
|
|
// received (see ExtensionPolicyService::CheckRequest).
|
|
//
|
|
// We use blockParsing() for document_start scripts (and styles) to
|
|
// ensure that the DOM remains blocked when scripts are still compiling.
|
|
try {
|
|
// NOTE: This is the ONLY await in this injectInto function!
|
|
const compiledResults = await promise;
|
|
if (sheetsIndex !== undefined) {
|
|
sheets = compiledResults[sheetsIndex];
|
|
}
|
|
if (scriptsIndex !== undefined) {
|
|
scripts = compiledResults[scriptsIndex];
|
|
}
|
|
} finally {
|
|
// gPendingScriptBlockers may be overwritten by another inject() call,
|
|
// so check that this is the latest inject() attempt before clearing.
|
|
if (gPendingScriptBlockers.get(context) === promiseSettled) {
|
|
gPendingScriptBlockers.delete(context);
|
|
}
|
|
}
|
|
}
|
|
|
|
let window = context.contentWindow;
|
|
if (!window) {
|
|
// context unloaded or went into bfcache before compilation completed.
|
|
return;
|
|
}
|
|
|
|
if (this.css.length || this.cssCodeHash) {
|
|
if (this.removeCSS) {
|
|
this.removeStyleSheets(window);
|
|
// The tabs.removeCSS and scripting.removeCSS are never combined with
|
|
// script execution, so we can now return early.
|
|
return;
|
|
}
|
|
// Make sure we've injected any related CSS before we run content scripts.
|
|
let { windowUtils } = window;
|
|
let type =
|
|
this.cssOrigin === "user"
|
|
? windowUtils.USER_SHEET
|
|
: windowUtils.AUTHOR_SHEET;
|
|
for (const sheet of sheets) {
|
|
runSafeSyncWithoutClone(windowUtils.addSheet, sheet, type);
|
|
}
|
|
}
|
|
|
|
const { extension } = context;
|
|
|
|
// The evaluations below may throw, in which case the promise will be
|
|
// automatically rejected.
|
|
lazy.ExtensionTelemetry.contentScriptInjection.stopwatchStart(
|
|
extension,
|
|
context
|
|
);
|
|
try {
|
|
if (this.world === "MAIN") {
|
|
return this.#injectIntoMainWorld(context, scripts, reportExceptions);
|
|
}
|
|
if (this.world === "USER_SCRIPT") {
|
|
return this.#injectIntoUserScriptWorld(
|
|
context,
|
|
scripts,
|
|
reportExceptions
|
|
);
|
|
}
|
|
return this.#injectIntoIsolatedWorld(context, scripts, reportExceptions);
|
|
} finally {
|
|
lazy.ExtensionTelemetry.contentScriptInjection.stopwatchFinish(
|
|
extension,
|
|
context
|
|
);
|
|
}
|
|
}
|
|
|
|
#injectIntoIsolatedWorld(context, scripts, reportExceptions) {
|
|
let result;
|
|
|
|
// Note: every script execution can potentially destroy the context, in
|
|
// which case context.cloneScope becomes null (bug 1403505).
|
|
for (let script of scripts) {
|
|
result = script.executeInGlobal(context.cloneScope, { reportExceptions });
|
|
}
|
|
|
|
if (this.jsCode) {
|
|
result = Cu.evalInSandbox(
|
|
this.jsCode,
|
|
context.cloneScope,
|
|
"latest",
|
|
// TODO bug 1651557: Use dynamic name to improve debugger experience.
|
|
"sandbox eval code",
|
|
1
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
#injectIntoUserScriptWorld(context, scripts, reportExceptions) {
|
|
let worldId = this.matcher.worldId;
|
|
let sandbox = lazy.ExtensionUserScriptsContent.sandboxFor(context, worldId);
|
|
|
|
let result;
|
|
// Note: every script execution can potentially destroy the context or
|
|
// navigate the window, in which case context.active will be false.
|
|
for (let script of scripts) {
|
|
if (!context.active) {
|
|
// Return instead of throw, to avoid logspam like bug 1403505.
|
|
return;
|
|
}
|
|
result = script.executeInGlobal(sandbox, { reportExceptions });
|
|
}
|
|
|
|
// NOTE: if userScripts.execute() is implemented (bug 1930776), we may have
|
|
// to account for this.jsCode here (via addJSCode).
|
|
|
|
return result;
|
|
}
|
|
|
|
#injectIntoMainWorld(context, scripts, reportExceptions) {
|
|
let result;
|
|
|
|
// Note: every script execution can potentially destroy the context or
|
|
// navigate the window, in which case context.contentWindow will be null,
|
|
// which would cause an error to be thrown (bug 1403505).
|
|
for (let script of scripts) {
|
|
result = script.executeInGlobal(context.contentWindow, {
|
|
reportExceptions,
|
|
});
|
|
}
|
|
|
|
// Note: string-based code execution (=our implementation of func+args in
|
|
// scripting.executeScript) is not handled here, because we compile it in
|
|
// addJSCode() and include it in the scripts array via getCompiledScripts().
|
|
// We cannot use context.contentWindow.eval() here because the web page's
|
|
// CSP may block it.
|
|
|
|
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 context where the caller intends to run the compiled script.
|
|
*
|
|
* @returns {PrecompiledScript[] | Promise<PrecompiledScript[]>}
|
|
*/
|
|
getCompiledScripts(context) {
|
|
let scriptPromises = this.compileScripts();
|
|
if (this.jsCodeCompiledScript) {
|
|
scriptPromises.push(this.jsCodeCompiledScript);
|
|
}
|
|
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.toString(),
|
|
error.fileName,
|
|
error.lineNumber,
|
|
error.columnNumber,
|
|
Ci.nsIScriptError.errorFlag,
|
|
"content javascript",
|
|
context.innerWindowID
|
|
)
|
|
);
|
|
});
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
return scripts;
|
|
}
|
|
|
|
getCompiledStyleSheets(window) {
|
|
const sheetPromises = this.loadCSS();
|
|
if (this.cssCodeHash) {
|
|
sheetPromises.push(this.cssCodeCache.get(this.cssCodeHash));
|
|
}
|
|
if (window) {
|
|
for (const sheetPromise of sheetPromises) {
|
|
sheetCacheDocuments.get(sheetPromise).add(window.document);
|
|
}
|
|
}
|
|
|
|
let sheets = sheetPromises.map(sheetPromise => sheetPromise.sheet);
|
|
if (!sheets.every(sheet => sheet)) {
|
|
return Promise.all(sheetPromises);
|
|
}
|
|
return sheets;
|
|
}
|
|
|
|
removeStyleSheets(window) {
|
|
let { windowUtils } = window;
|
|
|
|
let type =
|
|
this.cssOrigin === "user"
|
|
? windowUtils.USER_SHEET
|
|
: windowUtils.AUTHOR_SHEET;
|
|
|
|
for (let url of this.css) {
|
|
if (this.cssCache.has(url)) {
|
|
const sheetPromise = this.cssCache.get(url);
|
|
sheetCacheDocuments.get(sheetPromise).delete(window.document);
|
|
}
|
|
|
|
if (!window.closed) {
|
|
runSafeSyncWithoutClone(
|
|
windowUtils.removeSheetUsingURIString,
|
|
url,
|
|
type
|
|
);
|
|
}
|
|
}
|
|
|
|
const { cssCodeHash } = this;
|
|
|
|
if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) {
|
|
const sheetPromise = this.cssCodeCache.get(cssCodeHash);
|
|
sheetCacheDocuments.get(sheetPromise).delete(window.document);
|
|
if (sheetPromise.sheet && !window.closed) {
|
|
runSafeSyncWithoutClone(
|
|
windowUtils.removeSheet,
|
|
sheetPromise.styleURI,
|
|
type
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Represents a user script.
|
|
class UserScript extends Script {
|
|
/**
|
|
* @param {ExtensionChild & ExtensionChildContent} 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) {
|
|
let scripts = this.getCompiledScripts(context);
|
|
if (scripts instanceof Promise) {
|
|
// 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(scripts, { blockScriptCreated: false });
|
|
}
|
|
scripts = await scripts;
|
|
}
|
|
// NOTE: Other than "await scripts" above, there is no other "await" before
|
|
// execution. This ensures that document_start scripts execute immediately.
|
|
|
|
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: {
|
|
"browser-id": context.browserId,
|
|
"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.sys.mjs.
|
|
*/
|
|
export 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 = {
|
|
"browser-id": this.browserId,
|
|
"inner-window-id": this.innerWindowID,
|
|
addonId: extensionPrincipal.addonId,
|
|
};
|
|
|
|
let isMV2 = extension.manifestVersion == 2;
|
|
let wantGlobalProperties;
|
|
let sandboxContentSecurityPolicy;
|
|
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 = [];
|
|
// In MV3, the base CSP is enforced for content scripts. Overrides are
|
|
// currently not supported, but this was considered at some point, see
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1581611#c10
|
|
sandboxContentSecurityPolicy = extension.policy.baseCSP;
|
|
}
|
|
this.sandbox = Cu.Sandbox(principal, {
|
|
metadata,
|
|
sandboxName: `Content Script ${extension.policy.debugName}`,
|
|
sandboxPrototype: contentWindow,
|
|
sandboxContentSecurityPolicy,
|
|
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" });
|
|
}
|
|
}
|
|
Services.obs.notifyObservers(this.sandbox, "content-script-destroyed");
|
|
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 tracking the lifetime of a document, to manage the lifetime
|
|
// of ContentScriptContextChild instances for that document. When a caller
|
|
// wants to run extension code in a document (often in a sandbox) and need to
|
|
// have that code's lifetime be bound to the document, they call
|
|
// ExtensionContent.getContext() (indirectly via ExtensionChild's getContext()).
|
|
//
|
|
// As part of the initialization of a ContentScriptContextChild, the document's
|
|
// lifetime is tracked here, by DocumentManager. This DocumentManager ensures
|
|
// that the ContentScriptContextChild and any supporting caches are cleared
|
|
// when the document is destroyed.
|
|
DocumentManager = {
|
|
/** @type {Map<number, 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) {
|
|
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);
|
|
// When ExtensionContent.getContext() calls DocumentManager.getContexts,
|
|
// it is about to create ContentScriptContextChild instances that wraps
|
|
// the document. Call DocumentManager.lazyInit() to ensure that we have
|
|
// the relevant observers to close contexts as needed.
|
|
this.lazyInit();
|
|
}
|
|
|
|
return extensions;
|
|
},
|
|
|
|
// For test use only.
|
|
getContext(extensionId, window) {
|
|
for (let [extension, context] of this.getContexts(window)) {
|
|
if (extension.id === extensionId) {
|
|
return context;
|
|
}
|
|
}
|
|
},
|
|
|
|
getAllContentScriptGlobals() {
|
|
const sandboxes = [];
|
|
for (let extensions of this.contexts.values()) {
|
|
for (let ctx of extensions.values()) {
|
|
sandboxes.push(ctx.sandbox);
|
|
}
|
|
}
|
|
return sandboxes;
|
|
},
|
|
|
|
initExtensionContext(extension, window) {
|
|
// Note: getContext() always returns an ContentScriptContextChild instance.
|
|
// This can be a content script, or a sandbox holding the extension APIs
|
|
// for an extension document embedded in a non-extension document.
|
|
extension.getContext(window).injectAPI();
|
|
},
|
|
};
|
|
|
|
export var ExtensionContent = {
|
|
contentScripts,
|
|
|
|
shutdownExtension(extension) {
|
|
DocumentManager.shutdownExtension(extension);
|
|
},
|
|
|
|
// This helper is exported to be integrated in the devtools RDP actors,
|
|
// that can use it to retrieve all the existent WebExtensions ContentScripts
|
|
// running in the current content process and be able to show the
|
|
// ContentScripts source in the DevTools Debugger panel.
|
|
getAllContentScriptGlobals() {
|
|
return DocumentManager.getAllContentScriptGlobals();
|
|
},
|
|
|
|
initExtensionContext(extension, window) {
|
|
DocumentManager.initExtensionContext(extension, window);
|
|
},
|
|
|
|
/**
|
|
* Implementation of extension.getContext(window), which returns the "context"
|
|
* that wraps the current document in the window. The returned context is
|
|
* aware of the document's lifetime, including bfcache transitions.
|
|
*
|
|
* @param {ExtensionChild} extension
|
|
* @param {DOMWindow} window
|
|
* @returns {ContentScriptContextChild}
|
|
*/
|
|
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,
|
|
});
|
|
let script = contentScripts.get(matcher);
|
|
|
|
if (options.jsCode) {
|
|
script.addJSCode(options.jsCode);
|
|
delete options.jsCode;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|