/* 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 protocol = require("devtools/shared/protocol"); const { Cc, Ci, Cu, Cr } = require("chrome"); const { DevToolsServer } = require("devtools/server/devtools-server"); const Services = require("Services"); const ChromeUtils = require("ChromeUtils"); loader.lazyGetter( this, "NodeActor", () => require("devtools/server/actors/inspector/node").NodeActor, true ); const { webExtensionInspectedWindowSpec, } = require("devtools/shared/specs/addon/webextension-inspected-window"); const { WebExtensionPolicy } = Cu.getGlobalForObject( require("resource://gre/modules/XPCOMUtils.jsm") ); // 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 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 CustomizedReload(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.userAgent = params.userAgent; this.customizedReloadWindows = new WeakSet(); } CustomizedReload.prototype = { 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; if (this.userAgent) { this.browsingContext.customUserAgent = this.userAgent; } 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"); } // Reset the customized user agent. if ( this.userAgent && this.browsingContext.customUserAgent == this.userAgent ) { this.browsingContext.customUserAgent = null; } if (error) { this.rejectReloadCompleted(error); } else { this.resolveReloadCompleted(); } this.stopped = true; }, }; var WebExtensionInspectedWindowActor = protocol.ActorClassWithSpec( webExtensionInspectedWindowSpec, { /** * Created the WebExtension InspectedWindow actor */ initialize(conn, targetActor) { protocol.Actor.prototype.initialize.call(this, conn); this.targetActor = targetActor; }, destroy(conn) { protocol.Actor.prototype.destroy.call(this, conn); 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. */ reload(callerInfo, { ignoreCache, userAgent, injectedScript }) { if (isSystemPrincipalWindow(this.window)) { console.error( "Ignored inspectedWindow.reload on system principal target for " + `${callerInfo.url}:${callerInfo.lineNumber}` ); return {}; } 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, userAgent, ignoreCache, }); this.customizedReload .start() .then(() => { delete this.customizedReload; }) .catch(err => { delete this.customizedReload; console.error(err); }); } 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); } }; // 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.", ], }); } // Log the error for the user to know that the extension request has been denied // (the extension may not warn the user at all). const logEvalDenied = () => { logAccessDeniedWarning(window, callerInfo, extensionPolicy); }; if (isSystemPrincipalWindow(window)) { logEvalDenied(); // On denied JS evaluation, report it to the extension using the same data format // used in the corresponding chrome API method to report issues that are // not exceptions raised in the evaluated javascript code. return createExceptionInfoResult({ description: "Inspector protocol error: %s", details: [ "This target has a system principal. inspectedWindow.eval denied.", ], }); } const docPrincipalURI = window.document.nodePrincipal.URI; // Deny on document principals listed as restricted or // related to the about: pages (only about:blank and about:srcdoc are // allowed and their are expected to not have their about URI associated // to the principal). if ( WebExtensionPolicy.isRestrictedURI(docPrincipalURI) || docPrincipalURI.schemeIs("about") ) { logEvalDenied(); return createExceptionInfoResult({ description: "Inspector protocol error: %s %s", details: [ "This extension is not allowed on the current inspected window origin", docPrincipalURI.spec, ], }); } const windowAddonId = window.document.nodePrincipal.addonId; if (windowAddonId && extensionPolicy.id !== windowAddonId) { logEvalDenied(); return createExceptionInfoResult({ description: "Inspector protocol error: %s on %s", details: [ "This extension is not allowed to access this extension page.", window.document.location.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;