Source code

Revision control

Copy as Markdown

Other Tools

/* 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,
const {
DevToolsServer,
loader.lazyGetter(
this,
"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) {
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() {
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;