summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/addon/webextension-inspected-window.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/addon/webextension-inspected-window.js')
-rw-r--r--devtools/server/actors/addon/webextension-inspected-window.js680
1 files changed, 680 insertions, 0 deletions
diff --git a/devtools/server/actors/addon/webextension-inspected-window.js b/devtools/server/actors/addon/webextension-inspected-window.js
new file mode 100644
index 0000000000..e69a206c9d
--- /dev/null
+++ b/devtools/server/actors/addon/webextension-inspected-window.js
@@ -0,0 +1,680 @@
+/* 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";
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ webExtensionInspectedWindowSpec,
+} = require("resource://devtools/shared/specs/addon/webextension-inspected-window.js");
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+loader.lazyGetter(
+ this,
+ "NodeActor",
+ () =>
+ require("resource://devtools/server/actors/inspector/node.js").NodeActor,
+ true
+);
+
+// A weak set of the documents for which a warning message has been
+// already logged (so that we don't keep emitting the same warning if an
+// extension keeps calling the devtools.inspectedWindow.eval API method
+// when it fails to retrieve a result, but we do log the warning message
+// if the user reloads the window):
+//
+// WeakSet<Document>
+const deniedWarningDocuments = new WeakSet();
+
+function isSystemPrincipalWindow(window) {
+ return window.document.nodePrincipal.isSystemPrincipal;
+}
+
+// Create the exceptionInfo property in the format expected by a
+// WebExtension inspectedWindow.eval API calls.
+function createExceptionInfoResult(props) {
+ return {
+ exceptionInfo: {
+ isError: true,
+ code: "E_PROTOCOLERROR",
+ description: "Unknown Inspector protocol error",
+
+ // Apply the passed properties.
+ ...props,
+ },
+ };
+}
+
+// Show a warning message in the webconsole when an extension
+// eval request has been denied, so that the user knows about it
+// even if the extension doesn't report the error itself.
+function logAccessDeniedWarning(window, callerInfo, extensionPolicy) {
+ // Do not log the same warning multiple times for the same document.
+ if (deniedWarningDocuments.has(window.document)) {
+ return;
+ }
+
+ deniedWarningDocuments.add(window.document);
+
+ const { name } = extensionPolicy;
+
+ // System principals have a null nodePrincipal.URI and so we use
+ // the url from window.location.href.
+ const reportedURIorPrincipal = isSystemPrincipalWindow(window)
+ ? Services.io.newURI(window.location.href)
+ : window.document.nodePrincipal;
+
+ const error = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+
+ const msg = `The extension "${name}" is not allowed to access ${reportedURIorPrincipal.spec}`;
+
+ const innerWindowId = window.windowGlobalChild.innerWindowId;
+
+ const errorFlag = 0;
+
+ let { url, lineNumber } = callerInfo;
+
+ const callerURI = callerInfo.url && Services.io.newURI(callerInfo.url);
+
+ // callerInfo.url is not the full path to the file that called the WebExtensions
+ // API yet (Bug 1448878), and so we associate the error to the url of the extension
+ // manifest.json file as a fallback.
+ if (callerURI.filePath === "/") {
+ url = extensionPolicy.getURL("/manifest.json");
+ lineNumber = null;
+ }
+
+ error.initWithWindowID(
+ msg,
+ url,
+ lineNumber,
+ 0,
+ 0,
+ errorFlag,
+ "webExtensions",
+ innerWindowId
+ );
+ Services.console.logMessage(error);
+}
+
+function extensionAllowedToInspectPrincipal(extensionPolicy, principal) {
+ if (principal.isNullPrincipal) {
+ // data: and sandboxed documents.
+ //
+ // Rather than returning true unconditionally, we go through additional
+ // checks to prevent execution in sandboxed documents created by principals
+ // that extensions cannot access otherwise.
+ principal = principal.precursorPrincipal;
+ if (!principal) {
+ // Top-level about:blank, etc.
+ return true;
+ }
+ }
+ if (!principal.isContentPrincipal) {
+ return false;
+ }
+ const principalURI = principal.URI;
+ if (principalURI.schemeIs("https") || principalURI.schemeIs("http")) {
+ if (WebExtensionPolicy.isRestrictedURI(principalURI)) {
+ return false;
+ }
+ if (extensionPolicy.quarantinedFromURI(principalURI)) {
+ return false;
+ }
+ // Common case: http(s) allowed.
+ return true;
+ }
+
+ if (principalURI.schemeIs("moz-extension")) {
+ // Ordinarily, we don't allow extensions to execute arbitrary code in
+ // their own context. The devtools.inspectedWindow.eval API is a special
+ // case - this can only be used through the devtools_page feature, which
+ // requires the user to open the developer tools first. If an extension
+ // really wants to debug itself, we let it do so.
+ return extensionPolicy.id === principal.addonId;
+ }
+
+ if (principalURI.schemeIs("file")) {
+ return true;
+ }
+
+ return false;
+}
+
+class CustomizedReload {
+ constructor(params) {
+ this.docShell = params.targetActor.window.docShell;
+ this.docShell.QueryInterface(Ci.nsIWebProgress);
+
+ this.inspectedWindowEval = params.inspectedWindowEval;
+ this.callerInfo = params.callerInfo;
+
+ this.ignoreCache = params.ignoreCache;
+ this.injectedScript = params.injectedScript;
+
+ this.customizedReloadWindows = new WeakSet();
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]);
+
+ get window() {
+ return this.docShell.DOMWindow;
+ }
+
+ get webNavigation() {
+ return this.docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation);
+ }
+
+ get browsingContext() {
+ return this.docShell.browsingContext;
+ }
+
+ start() {
+ if (!this.waitForReloadCompleted) {
+ this.waitForReloadCompleted = new Promise((resolve, reject) => {
+ this.resolveReloadCompleted = resolve;
+ this.rejectReloadCompleted = reject;
+
+ let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+
+ if (this.ignoreCache) {
+ reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+
+ try {
+ if (this.injectedScript) {
+ // Listen to the newly created document elements only if there is an
+ // injectedScript to evaluate.
+ Services.obs.addObserver(this, "initial-document-element-inserted");
+ }
+
+ // Watch the loading progress and clear the current CustomizedReload once the
+ // page has been reloaded (or if its reloading has been interrupted).
+ this.docShell.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+
+ this.webNavigation.reload(reloadFlags);
+ } catch (err) {
+ // Cancel the injected script listener if the reload fails
+ // (which will also report the error by rejecting the promise).
+ this.stop(err);
+ }
+ });
+ }
+
+ return this.waitForReloadCompleted;
+ }
+
+ observe(subject, topic, data) {
+ if (topic !== "initial-document-element-inserted") {
+ return;
+ }
+
+ const document = subject;
+ const window = document?.defaultView;
+
+ // Filter out non interesting documents.
+ if (!document || !document.location || !window) {
+ return;
+ }
+
+ const subjectDocShell = window.docShell;
+
+ // Keep track of the set of window objects where we are going to inject
+ // the injectedScript: the top level window and all its descendant
+ // that are still of type content (filtering out loaded XUL pages, if any).
+ if (window == this.window) {
+ this.customizedReloadWindows.add(window);
+ } else if (subjectDocShell.sameTypeParent) {
+ const parentWindow = subjectDocShell.sameTypeParent.domWindow;
+ if (parentWindow && this.customizedReloadWindows.has(parentWindow)) {
+ this.customizedReloadWindows.add(window);
+ }
+ }
+
+ if (this.customizedReloadWindows.has(window)) {
+ const { apiErrorResult } = this.inspectedWindowEval(
+ this.callerInfo,
+ this.injectedScript,
+ {},
+ window
+ );
+
+ // Log only apiErrorResult, because no one is waiting for the
+ // injectedScript result, and any exception is going to be logged
+ // in the inspectedWindow webconsole.
+ if (apiErrorResult) {
+ console.error(
+ "Unexpected Error in injectedScript during inspectedWindow.reload for",
+ `${this.callerInfo.url}:${this.callerInfo.lineNumber}`,
+ apiErrorResult
+ );
+ }
+ }
+ }
+
+ onStateChange(webProgress, request, state, status) {
+ if (webProgress.DOMWindow !== this.window) {
+ return;
+ }
+
+ if (state & Ci.nsIWebProgressListener.STATE_STOP) {
+ if (status == Cr.NS_BINDING_ABORTED) {
+ // The customized reload has been interrupted and we can clear
+ // the CustomizedReload and reject the promise.
+ const url = this.window.location.href;
+ this.stop(
+ new Error(
+ `devtools.inspectedWindow.reload on ${url} has been interrupted`
+ )
+ );
+ } else {
+ // Once the top level frame has been loaded, we can clear the customized reload
+ // and resolve the promise.
+ this.stop();
+ }
+ }
+ }
+
+ stop(error) {
+ if (this.stopped) {
+ return;
+ }
+
+ this.docShell.removeProgressListener(this);
+
+ if (this.injectedScript) {
+ Services.obs.removeObserver(this, "initial-document-element-inserted");
+ }
+
+ if (error) {
+ this.rejectReloadCompleted(error);
+ } else {
+ this.resolveReloadCompleted();
+ }
+
+ this.stopped = true;
+ }
+}
+
+class WebExtensionInspectedWindowActor extends Actor {
+ /**
+ * Created the WebExtension InspectedWindow actor
+ */
+ constructor(conn, targetActor) {
+ super(conn, webExtensionInspectedWindowSpec);
+ this.targetActor = targetActor;
+ }
+
+ destroy(conn) {
+ super.destroy();
+
+ if (this.customizedReload) {
+ this.customizedReload.stop(
+ new Error("WebExtensionInspectedWindowActor destroyed")
+ );
+ delete this.customizedReload;
+ }
+
+ if (this._dbg) {
+ this._dbg.disable();
+ delete this._dbg;
+ }
+ }
+
+ get dbg() {
+ if (this._dbg) {
+ return this._dbg;
+ }
+
+ this._dbg = this.targetActor.makeDebugger();
+ return this._dbg;
+ }
+
+ get window() {
+ return this.targetActor.window;
+ }
+
+ get webNavigation() {
+ return this.targetActor.webNavigation;
+ }
+
+ createEvalBindings(dbgWindow, options) {
+ const bindings = Object.create(null);
+
+ let selectedDOMNode;
+
+ if (options.toolboxSelectedNodeActorID) {
+ const actor = DevToolsServer.searchAllConnectionsForActor(
+ options.toolboxSelectedNodeActorID
+ );
+ if (actor && actor instanceof NodeActor) {
+ selectedDOMNode = actor.rawNode;
+ }
+ }
+
+ Object.defineProperty(bindings, "$0", {
+ enumerable: true,
+ configurable: true,
+ get: () => {
+ if (selectedDOMNode && !Cu.isDeadWrapper(selectedDOMNode)) {
+ return dbgWindow.makeDebuggeeValue(selectedDOMNode);
+ }
+
+ return undefined;
+ },
+ });
+
+ // This function is used by 'eval' and 'reload' requests, but only 'eval'
+ // passes 'toolboxConsoleActor' from the client side in order to set
+ // the 'inspect' binding.
+ Object.defineProperty(bindings, "inspect", {
+ enumerable: true,
+ configurable: true,
+ value: dbgWindow.makeDebuggeeValue(object => {
+ const consoleActor = DevToolsServer.searchAllConnectionsForActor(
+ options.toolboxConsoleActorID
+ );
+ if (consoleActor) {
+ const dbgObj = consoleActor.makeDebuggeeValue(object);
+ consoleActor.inspectObject(
+ dbgObj,
+ "webextension-devtools-inspectedWindow-eval"
+ );
+ } else {
+ // TODO(rpl): evaluate if it would be better to raise an exception
+ // to the caller code instead.
+ console.error("Toolbox Console RDP Actor not found");
+ }
+ }),
+ });
+
+ return bindings;
+ }
+
+ /**
+ * Reload the target tab, optionally bypass cache, customize the userAgent and/or
+ * inject a script in targeted document or any of its sub-frame.
+ *
+ * @param {webExtensionCallerInfo} callerInfo
+ * the addonId and the url (the addon base url or the url of the actual caller
+ * filename and lineNumber) used to log useful debugging information in the
+ * produced error logs and eval stack trace.
+ *
+ * @param {webExtensionReloadOptions} options
+ * used to optionally enable the reload customizations.
+ * @param {boolean|undefined} options.ignoreCache
+ * enable/disable the cache bypass headers.
+ * @param {string|undefined} options.userAgent
+ * customize the userAgent during the page reload.
+ * @param {string|undefined} options.injectedScript
+ * evaluate the provided javascript code in the top level and every sub-frame
+ * created during the page reload, before any other script in the page has been
+ * executed.
+ */
+ async reload(callerInfo, { ignoreCache, userAgent, injectedScript }) {
+ if (isSystemPrincipalWindow(this.window)) {
+ console.error(
+ "Ignored inspectedWindow.reload on system principal target for " +
+ `${callerInfo.url}:${callerInfo.lineNumber}`
+ );
+ return {};
+ }
+
+ await new Promise(resolve => {
+ const delayedReload = () => {
+ // This won't work while the browser is shutting down and we don't really
+ // care.
+ if (Services.startup.shuttingDown) {
+ return;
+ }
+
+ if (injectedScript || userAgent) {
+ if (this.customizedReload) {
+ // TODO(rpl): check what chrome does, and evaluate if queue the new reload
+ // after the current one has been completed.
+ console.error(
+ "Reload already in progress. Ignored inspectedWindow.reload for " +
+ `${callerInfo.url}:${callerInfo.lineNumber}`
+ );
+ return;
+ }
+
+ try {
+ this.customizedReload = new CustomizedReload({
+ targetActor: this.targetActor,
+ inspectedWindowEval: this.eval.bind(this),
+ callerInfo,
+ injectedScript,
+ ignoreCache,
+ });
+
+ this.customizedReload
+ .start()
+ .catch(err => {
+ console.error(err);
+ })
+ .then(() => {
+ delete this.customizedReload;
+ resolve();
+ });
+ } catch (err) {
+ // Cancel the customized reload (if any) on exception during the
+ // reload setup.
+ if (this.customizedReload) {
+ this.customizedReload.stop(err);
+ }
+ throw err;
+ }
+ } else {
+ // If there is no custom user agent and/or injected script, then
+ // we can reload the target without subscribing any observer/listener.
+ let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (ignoreCache) {
+ reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+ this.webNavigation.reload(reloadFlags);
+ resolve();
+ }
+ };
+
+ // Execute the reload in a dispatched runnable, so that we can
+ // return the reply to the caller before the reload is actually
+ // started.
+ Services.tm.dispatchToMainThread(delayedReload);
+ });
+
+ return {};
+ }
+
+ /**
+ * Evaluate the provided javascript code in a target window (that is always the
+ * targetActor window when called through RDP protocol, or the passed
+ * customTargetWindow when called directly from the CustomizedReload instances).
+ *
+ * @param {webExtensionCallerInfo} callerInfo
+ * the addonId and the url (the addon base url or the url of the actual caller
+ * filename and lineNumber) used to log useful debugging information in the
+ * produced error logs and eval stack trace.
+ *
+ * @param {string} expression
+ * the javascript code to be evaluated in the target window
+ *
+ * @param {webExtensionEvalOptions} evalOptions
+ * used to optionally enable the eval customizations.
+ * NOTE: none of the eval options is currently implemented, they will be already
+ * reported as unsupported by the WebExtensions schema validation wrappers, but
+ * an additional level of error reporting is going to be applied here, so that
+ * if the server and the client have different ideas of which option is supported
+ * the eval call result will contain detailed informations (in the format usually
+ * expected for errors not raised in the evaluated javascript code).
+ *
+ * @param {DOMWindow|undefined} customTargetWindow
+ * Used in the CustomizedReload instances to evaluate the `injectedScript`
+ * javascript code in every sub-frame of the target window during the tab reload.
+ * NOTE: this parameter is not part of the RDP protocol exposed by this actor, when
+ * it is called over the remote debugging protocol the target window is always
+ * `targetActor.window`.
+ */
+ // eslint-disable-next-line complexity
+ eval(callerInfo, expression, options, customTargetWindow) {
+ const window = customTargetWindow || this.window;
+ options = options || {};
+
+ const extensionPolicy = WebExtensionPolicy.getByID(callerInfo.addonId);
+
+ if (!extensionPolicy) {
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s %s",
+ details: ["Caller extension not found for", callerInfo.url],
+ });
+ }
+
+ if (!window) {
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s",
+ details: [
+ "The target window is not defined. inspectedWindow.eval not executed.",
+ ],
+ });
+ }
+
+ if (
+ !extensionAllowedToInspectPrincipal(
+ extensionPolicy,
+ window.document.nodePrincipal
+ )
+ ) {
+ // Log the error for the user to know that the extension request has been
+ // denied (the extension may not warn the user at all).
+ logAccessDeniedWarning(window, callerInfo, extensionPolicy);
+
+ // The error message is generic here. If access is disallowed, we do not
+ // expose the URL either.
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s",
+ details: [
+ "This extension is not allowed on the current inspected window origin",
+ ],
+ });
+ }
+
+ // Raise an error on the unsupported options.
+ if (
+ options.frameURL ||
+ options.contextSecurityOrigin ||
+ options.useContentScriptContext
+ ) {
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s",
+ details: [
+ "The inspectedWindow.eval options are currently not supported",
+ ],
+ });
+ }
+
+ const dbgWindow = this.dbg.makeGlobalObjectReference(window);
+
+ let evalCalledFrom = callerInfo.url;
+ if (callerInfo.lineNumber) {
+ evalCalledFrom += `:${callerInfo.lineNumber}`;
+ }
+
+ const bindings = this.createEvalBindings(dbgWindow, options);
+
+ const result = dbgWindow.executeInGlobalWithBindings(expression, bindings, {
+ url: `debugger eval called from ${evalCalledFrom} - eval code`,
+ });
+
+ let evalResult;
+
+ if (result) {
+ if ("return" in result) {
+ evalResult = result.return;
+ } else if ("yield" in result) {
+ evalResult = result.yield;
+ } else if ("throw" in result) {
+ const throwErr = result.throw;
+
+ // XXXworkers: Calling unsafeDereference() returns an object with no
+ // toString method in workers. See Bug 1215120.
+ const unsafeDereference =
+ throwErr &&
+ typeof throwErr === "object" &&
+ throwErr.unsafeDereference();
+ const message = unsafeDereference?.toString
+ ? unsafeDereference.toString()
+ : String(throwErr);
+ const stack = unsafeDereference?.stack ? unsafeDereference.stack : null;
+
+ return {
+ exceptionInfo: {
+ isException: true,
+ value: `${message}\n\t${stack}`,
+ },
+ };
+ }
+ } else {
+ // TODO(rpl): can the result of executeInGlobalWithBinding be null or
+ // undefined? (which means that it is not a return, a yield or a throw).
+ console.error(
+ "Unexpected empty inspectedWindow.eval result for",
+ `${callerInfo.url}:${callerInfo.lineNumber}`
+ );
+ }
+
+ if (evalResult) {
+ try {
+ // Return the evalResult as a grip (used by the WebExtensions
+ // devtools inspector's sidebar.setExpression API method).
+ if (options.evalResultAsGrip) {
+ if (!options.toolboxConsoleActorID) {
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s - %s",
+ details: [
+ "Unexpected invalid sidebar panel expression request",
+ "missing toolboxConsoleActorID",
+ ],
+ });
+ }
+
+ const consoleActor = DevToolsServer.searchAllConnectionsForActor(
+ options.toolboxConsoleActorID
+ );
+
+ return { valueGrip: consoleActor.createValueGrip(evalResult) };
+ }
+
+ if (evalResult && typeof evalResult === "object") {
+ evalResult = evalResult.unsafeDereference();
+ }
+ evalResult = JSON.parse(JSON.stringify(evalResult));
+ } catch (err) {
+ // The evaluation result cannot be sent over the RDP Protocol,
+ // report it as with the same data format used in the corresponding
+ // chrome API method.
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s",
+ details: [String(err)],
+ });
+ }
+ }
+
+ return { value: evalResult };
+ }
+}
+
+exports.WebExtensionInspectedWindowActor = WebExtensionInspectedWindowActor;