diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
commit | 0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d (patch) | |
tree | a31f07c9bcca9d56ce61e9a1ffd30ef350d513aa /devtools/server/actors/webconsole | |
parent | Initial commit. (diff) | |
download | firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.tar.xz firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.zip |
Adding upstream version 115.8.0esr.upstream/115.8.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/webconsole')
17 files changed, 3000 insertions, 0 deletions
diff --git a/devtools/server/actors/webconsole/commands/manager.js b/devtools/server/actors/webconsole/commands/manager.js new file mode 100644 index 0000000000..3fa274a0f5 --- /dev/null +++ b/devtools/server/actors/webconsole/commands/manager.js @@ -0,0 +1,577 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + ["isCommand"], + "resource://devtools/server/actors/webconsole/commands/parser.js", + true +); + +/** + * WebConsole commands manager. + * + * Defines a set of functions / variables ("commands") that are available from + * the Web Console but not from the web page. + * + */ +const WebConsoleCommandsManager = { + _registeredCommands: new Map(), + + /** + * Register a new command. + * @param {string} name The command name (exemple: "$") + * @param {(function|object)} command The command to register. + * It can be a function so the command is a function (like "$()"), + * or it can also be a property descriptor to describe a getter(like + * "$0"). + * + * The command function or the command getter are passed a owner object as + * their first parameter (see the example below). + * + * @example + * + * WebConsoleCommandsManager.register("$", function JSTH_$(owner, selector) + * { + * return owner.window.document.querySelector(selector); + * }); + * + * WebConsoleCommandsManager.register("$0", { + * get: function(owner) { + * return owner.makeDebuggeeValue(owner.selectedNode); + * } + * }); + */ + register(name, command) { + if ( + typeof command != "function" && + !(typeof command == "object" && typeof command.get == "function") + ) { + throw new Error( + "Invalid web console command. It can only be a function, or an object with a function as 'get' attribute" + ); + } + this._registeredCommands.set(name, command); + }, + + /** + * Return the name of all registered commands. + * + * @return {array} List of all command names. + */ + getAllCommandNames() { + return [...this._registeredCommands.keys()]; + }, + + /** + * There is two types of "commands" here. + * + * - Functions or variables exposed in the scope of the evaluated string from the WebConsole input. + * Example: $(), $0, copy(), clear(),... + * - "True commands", which can also be ran from the WebConsole input with ":" prefix. + * Example: this list of commands. + * Note that some "true commands" are not exposed as function (see getColonOnlyCommandNames). + * + * The following list distinguish these "true commands" from the first category. + * It especially avoid any JavaScript evaluation when the frontend tries to execute + * a string starting with ':' character. + */ + getAllColonCommandNames() { + return ["block", "help", "history", "screenshot", "unblock"]; + }, + + /** + * Some commands are not exposed in the scope of the evaluated string, + * and can only be used via `:command-name`. + */ + getColonOnlyCommandNames() { + return ["screenshot"]; + }, + + /** + * Map of all command objects keyed by command name. + * Commands object are the objects passed to register() method. + * + * @return {Map<string -> command>} + */ + getAllCommands() { + return this._registeredCommands; + }, + + /** + * Is the command name possibly overriding a symbol which + * already exists in the paused frame, or global into which + * we are about to execute into? + */ + _isCommandNameAlreadyInScope(name, frame, dbgGlobal) { + // Fallback on global scope when Debugger.Frame doesn't come along an Environment. + if (frame && frame.environment) { + return !!frame.environment.find(name); + } + return !!dbgGlobal.getOwnPropertyDescriptor(name); + }, + + /** + * Create an object with the API we expose to the Web Console during + * JavaScript evaluation. + * This object inherits properties and methods from the Web Console actor. + * + * @param object consoleActor + * The related web console actor evaluating some code. + * @param object debuggerGlobal + * A Debugger.Object that wraps a content global. This is used for the + * Web Console Commands. + * @param object frame (optional) + * The frame where the string was evaluated. + * @param string evalInput + * String to evaluate. + * @param string selectedNodeActorID + * The Node actor ID of the currently selected DOM Element, if any is selected. + * + * @return object + * Object with two properties: + * - 'bindings', the object with all commands set as attribute on this object. + * - 'getHelperResult', a live getter returning the additional data the last command + * which executed want to convey to the frontend. + * (The return value of commands isn't returned to the client but it only + * returned to the code ran from console evaluation) + */ + getWebConsoleCommands( + consoleActor, + debuggerGlobal, + frame, + evalInput, + selectedNodeActorID + ) { + const bindings = Object.create(null); + + const owner = { + window: consoleActor.evalGlobal, + makeDebuggeeValue: debuggerGlobal.makeDebuggeeValue.bind(debuggerGlobal), + createValueGrip: consoleActor.createValueGrip.bind(consoleActor), + preprocessDebuggerObject: + consoleActor.preprocessDebuggerObject.bind(consoleActor), + helperResult: null, + consoleActor, + evalInput, + }; + if (selectedNodeActorID) { + const actor = consoleActor.conn.getActor(selectedNodeActorID); + if (actor) { + owner.selectedNode = actor.rawNode; + } + } + + const evalGlobal = consoleActor.evalGlobal; + function maybeExport(obj, name) { + if (typeof obj[name] != "function") { + return; + } + + // By default, chrome-implemented functions that are exposed to content + // refuse to accept arguments that are cross-origin for the caller. This + // is generally the safe thing, but causes problems for certain console + // helpers like cd(), where we users sometimes want to pass a cross-origin + // window. To circumvent this restriction, we use exportFunction along + // with a special option designed for this purpose. See bug 1051224. + obj[name] = Cu.exportFunction(obj[name], evalGlobal, { + allowCrossOriginArguments: true, + }); + } + + // Not supporting extra commands in workers yet. This should be possible to + // add one by one as long as they don't require jsm, Cu, etc. + const commands = isWorker ? [] : this.getAllCommands(); + + // Is it a command evaluation, starting with ':' prefix? + const isCmd = isCommand(evalInput); + + const colonOnlyCommandNames = this.getColonOnlyCommandNames(); + for (const [name, command] of commands) { + // When we are running command via `:` prefix, no user code is being ran and only the command executes, + // so always expose the commands as the command will try to call its JavaScript method (see getEvalInput). + // Otherwise, when we run user code, we want to avoid overriding existing symbols with commands. + // Also ignore commands which can only be run with the `:` prefix. + if ( + !isCmd && + (this._isCommandNameAlreadyInScope(name, frame, debuggerGlobal) || + colonOnlyCommandNames.includes(name)) + ) { + continue; + } + + const descriptor = { + // We force the enumerability and the configurability (so the + // WebConsoleActor can reconfigure the property). + enumerable: true, + configurable: true, + }; + + if (typeof command === "function") { + // Function commands + descriptor.value = command.bind(undefined, owner); + maybeExport(descriptor, "value"); + // Make sure the helpers can be used during eval. + descriptor.value = debuggerGlobal.makeDebuggeeValue(descriptor.value); + } else if (typeof command?.get === "function") { + // Getter commands + descriptor.get = command.get.bind(undefined, owner); + maybeExport(descriptor, "get"); + } + Object.defineProperty(bindings, name, descriptor); + } + + return { + // Use a method as commands will update owner.helperResult later + getHelperResult() { + return owner.helperResult; + }, + bindings, + }; + }, +}; + +exports.WebConsoleCommandsManager = WebConsoleCommandsManager; + +/* + * Built-in commands. + * + * A list of helper functions used by Firebug can be found here: + * http://getfirebug.com/wiki/index.php/Command_Line_API + */ + +/** + * Find a node by ID. + * + * @param string id + * The ID of the element you want. + * @return Node or null + * The result of calling document.querySelector(selector). + */ +WebConsoleCommandsManager.register("$", function (owner, selector) { + try { + return owner.window.document.querySelector(selector); + } catch (err) { + // Throw an error like `err` but that belongs to `owner.window`. + throw new owner.window.DOMException(err.message, err.name); + } +}); + +/** + * Find the nodes matching a CSS selector. + * + * @param string selector + * A string that is passed to window.document.querySelectorAll. + * @return NodeList + * Returns the result of document.querySelectorAll(selector). + */ +WebConsoleCommandsManager.register("$$", function (owner, selector) { + let nodes; + try { + nodes = owner.window.document.querySelectorAll(selector); + } catch (err) { + // Throw an error like `err` but that belongs to `owner.window`. + throw new owner.window.DOMException(err.message, err.name); + } + + // Calling owner.window.Array.from() doesn't work without accessing the + // wrappedJSObject, so just loop through the results instead. + const result = new owner.window.Array(); + for (let i = 0; i < nodes.length; i++) { + result.push(nodes[i]); + } + return result; +}); + +/** + * Returns the result of the last console input evaluation + * + * @return object|undefined + * Returns last console evaluation or undefined + */ +WebConsoleCommandsManager.register("$_", { + get(owner) { + return owner.consoleActor.getLastConsoleInputEvaluation(); + }, +}); + +/** + * Runs an xPath query and returns all matched nodes. + * + * @param string xPath + * xPath search query to execute. + * @param [optional] Node context + * Context to run the xPath query on. Uses window.document if not set. + * @param [optional] string|number resultType + Specify the result type. Default value XPathResult.ANY_TYPE + * @return array of Node + */ +WebConsoleCommandsManager.register( + "$x", + function ( + owner, + xPath, + context, + resultType = owner.window.XPathResult.ANY_TYPE + ) { + const nodes = new owner.window.Array(); + // Not waiving Xrays, since we want the original Document.evaluate function, + // instead of anything that's been redefined. + const doc = owner.window.document; + context = context || doc; + switch (resultType) { + case "number": + resultType = owner.window.XPathResult.NUMBER_TYPE; + break; + + case "string": + resultType = owner.window.XPathResult.STRING_TYPE; + break; + + case "bool": + resultType = owner.window.XPathResult.BOOLEAN_TYPE; + break; + + case "node": + resultType = owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE; + break; + + case "nodes": + resultType = owner.window.XPathResult.UNORDERED_NODE_ITERATOR_TYPE; + break; + } + const results = doc.evaluate(xPath, context, null, resultType, null); + if (results.resultType === owner.window.XPathResult.NUMBER_TYPE) { + return results.numberValue; + } + if (results.resultType === owner.window.XPathResult.STRING_TYPE) { + return results.stringValue; + } + if (results.resultType === owner.window.XPathResult.BOOLEAN_TYPE) { + return results.booleanValue; + } + if ( + results.resultType === owner.window.XPathResult.ANY_UNORDERED_NODE_TYPE || + results.resultType === owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE + ) { + return results.singleNodeValue; + } + if ( + results.resultType === + owner.window.XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE || + results.resultType === owner.window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE + ) { + for (let i = 0; i < results.snapshotLength; i++) { + nodes.push(results.snapshotItem(i)); + } + return nodes; + } + + let node; + while ((node = results.iterateNext())) { + nodes.push(node); + } + + return nodes; + } +); + +/** + * Returns the currently selected object in the highlighter. + * + * @return Object representing the current selection in the + * Inspector, or null if no selection exists. + */ +WebConsoleCommandsManager.register("$0", { + get(owner) { + return owner.makeDebuggeeValue(owner.selectedNode); + }, +}); + +/** + * Clears the output of the WebConsole. + */ +WebConsoleCommandsManager.register("clear", function (owner) { + owner.helperResult = { + type: "clearOutput", + }; +}); + +/** + * Clears the input history of the WebConsole. + */ +WebConsoleCommandsManager.register("clearHistory", function (owner) { + owner.helperResult = { + type: "clearHistory", + }; +}); + +/** + * Returns the result of Object.keys(object). + * + * @param object object + * Object to return the property names from. + * @return array of strings + */ +WebConsoleCommandsManager.register("keys", function (owner, object) { + // Need to waive Xrays so we can iterate functions and accessor properties + return Cu.cloneInto(Object.keys(Cu.waiveXrays(object)), owner.window); +}); + +/** + * Returns the values of all properties on object. + * + * @param object object + * Object to display the values from. + * @return array of string + */ +WebConsoleCommandsManager.register("values", function (owner, object) { + const values = []; + // Need to waive Xrays so we can iterate functions and accessor properties + const waived = Cu.waiveXrays(object); + const names = Object.getOwnPropertyNames(waived); + + for (const name of names) { + values.push(waived[name]); + } + + return Cu.cloneInto(values, owner.window); +}); + +/** + * Opens a help window in MDN. + */ +WebConsoleCommandsManager.register("help", function (owner) { + owner.helperResult = { type: "help" }; +}); + +/** + * Inspects the passed object. This is done by opening the PropertyPanel. + * + * @param object object + * Object to inspect. + */ +WebConsoleCommandsManager.register( + "inspect", + function (owner, object, forceExpandInConsole = false) { + const dbgObj = owner.preprocessDebuggerObject( + owner.makeDebuggeeValue(object) + ); + + const grip = owner.createValueGrip(dbgObj); + owner.helperResult = { + type: "inspectObject", + input: owner.evalInput, + object: grip, + forceExpandInConsole, + }; + } +); + +/** + * Copy the String representation of a value to the clipboard. + * + * @param any value + * A value you want to copy as a string. + * @return void + */ +WebConsoleCommandsManager.register("copy", function (owner, value) { + let payload; + try { + if (Element.isInstance(value)) { + payload = value.outerHTML; + } else if (typeof value == "string") { + payload = value; + } else { + payload = JSON.stringify(value, null, " "); + } + } catch (ex) { + owner.helperResult = { + type: "error", + message: "webconsole.error.commands.copyError", + messageArgs: [ex.toString()], + }; + return; + } + owner.helperResult = { + type: "copyValueToClipboard", + value: payload, + }; +}); + +/** + * Take a screenshot of a page. + * + * @param object args + * The arguments to be passed to the screenshot + * @return void + */ +WebConsoleCommandsManager.register("screenshot", function (owner, args = {}) { + owner.helperResult = { + type: "screenshotOutput", + args, + }; +}); + +/** + * Shows a history of commands and expressions previously executed within the command line. + * + * @param object args + * The arguments to be passed to the history + * @return void + */ +WebConsoleCommandsManager.register("history", function (owner, args = {}) { + owner.helperResult = { + type: "historyOutput", + args, + }; +}); + +/** + * Block specific resource from loading + * + * @param object args + * an object with key "url", i.e. a filter + * + * @return void + */ +WebConsoleCommandsManager.register("block", function (owner, args = {}) { + if (!args.url) { + owner.helperResult = { + type: "error", + message: "webconsole.messages.commands.blockArgMissing", + }; + return; + } + + owner.helperResult = { + type: "blockURL", + args, + }; +}); + +/* + * Unblock a blocked a resource + * + * @param object filter + * an object with key "url", i.e. a filter + * + * @return void + */ +WebConsoleCommandsManager.register("unblock", function (owner, args = {}) { + if (!args.url) { + owner.helperResult = { + type: "error", + message: "webconsole.messages.commands.blockArgMissing", + }; + return; + } + + owner.helperResult = { + type: "unblockURL", + args, + }; +}); diff --git a/devtools/server/actors/webconsole/commands/moz.build b/devtools/server/actors/webconsole/commands/moz.build new file mode 100644 index 0000000000..9e0516b172 --- /dev/null +++ b/devtools/server/actors/webconsole/commands/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "manager.js", + "parser.js", +) diff --git a/devtools/server/actors/webconsole/commands/parser.js b/devtools/server/actors/webconsole/commands/parser.js new file mode 100644 index 0000000000..b37d489c5f --- /dev/null +++ b/devtools/server/actors/webconsole/commands/parser.js @@ -0,0 +1,249 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + ["WebConsoleCommandsManager"], + "resource://devtools/server/actors/webconsole/commands/manager.js", + true +); + +const COMMAND = "command"; +const KEY = "key"; +const ARG = "arg"; + +const COMMAND_PREFIX = /^:/; +const KEY_PREFIX = /^--/; + +// default value for flags +const DEFAULT_VALUE = true; +const COMMAND_DEFAULT_FLAG = { + block: "url", + screenshot: "filename", + unblock: "url", +}; + +/** + * When given a string that begins with `:` and a unix style string, + * format a JS like object. + * This is intended to be used by the WebConsole actor only. + * + * @param String string + * A string to format that begins with `:`. + * + * @returns String formatted as `command({ ..args })` + */ +function formatCommand(string) { + if (!isCommand(string)) { + throw Error("formatCommand was called without `:`"); + } + const tokens = string.trim().split(/\s+/).map(createToken); + const { command, args } = parseCommand(tokens); + const argsString = formatArgs(args); + return `${command}(${argsString})`; +} + +/** + * collapses the array of arguments from the parsed command into + * a single string + * + * @param Object tree + * A tree object produced by parseCommand + * + * @returns String formatted as ` { key: value, ... } ` or an empty string + */ +function formatArgs(args) { + return Object.keys(args).length ? JSON.stringify(args) : ""; +} + +/** + * creates a token object depending on a string which as a prefix, + * either `:` for a command or `--` for a key, or nothing for an argument + * + * @param String string + * A string to use as the basis for the token + * + * @returns Object Token Object, with the following shape + * { type: String, value: String } + */ +function createToken(string) { + if (isCommand(string)) { + const value = string.replace(COMMAND_PREFIX, ""); + if ( + !value || + !WebConsoleCommandsManager.getAllColonCommandNames().includes(value) + ) { + throw Error(`'${value}' is not a valid command`); + } + return { type: COMMAND, value }; + } + if (isKey(string)) { + const value = string.replace(KEY_PREFIX, ""); + if (!value) { + throw Error("invalid flag"); + } + return { type: KEY, value }; + } + return { type: ARG, value: string }; +} + +/** + * returns a command Tree object for a set of tokens + * + * + * @param Array Tokens tokens + * An array of Token objects + * + * @returns Object Tree Object, with the following shape + * { command: String, args: Array of Strings } + */ +function parseCommand(tokens) { + let command = null; + const args = {}; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.type === COMMAND) { + if (command) { + // we are throwing here because two commands have been passed and it is unclear + // what the user's intention was + throw Error("Invalid command"); + } + command = token.value; + } + + if (token.type === KEY) { + const nextTokenIndex = i + 1; + const nextToken = tokens[nextTokenIndex]; + let values = args[token.value] || DEFAULT_VALUE; + if (nextToken && nextToken.type === ARG) { + const { value, offset } = collectString( + nextToken, + tokens, + nextTokenIndex + ); + // in order for JSON.stringify to correctly output values, they must be correctly + // typed + // As per the old GCLI documentation, we can only have one value associated with a + // flag but multiple flags with the same name can exist and should be combined + // into and array. Here we are associating only the value on the right hand + // side if it is of type `arg` as a single value; the second case initializes + // an array, and the final case pushes a value to an existing array + const typedValue = getTypedValue(value); + if (values === DEFAULT_VALUE) { + values = typedValue; + } else if (!Array.isArray(values)) { + values = [values, typedValue]; + } else { + values.push(typedValue); + } + // skip the next token since we have already consumed it + i = nextTokenIndex + offset; + } + args[token.value] = values; + } + + // Since this has only been implemented for screenshot, we can only have one default + // value. Eventually we may have more default values. For now, ignore multiple + // unflagged args + const defaultFlag = COMMAND_DEFAULT_FLAG[command]; + if (token.type === ARG && !args[defaultFlag]) { + const { value, offset } = collectString(token, tokens, i); + args[defaultFlag] = getTypedValue(value); + i = i + offset; + } + } + return { command, args }; +} + +const stringChars = ['"', "'", "`"]; +function isStringChar(testChar) { + return stringChars.includes(testChar); +} + +function checkLastChar(string, testChar) { + const lastChar = string[string.length - 1]; + return lastChar === testChar; +} + +function hasUnescapedChar(value, char, rightOffset, leftOffset) { + const lastPos = value.length - 1; + const string = value.slice(rightOffset, lastPos - leftOffset); + const index = string.indexOf(char); + if (index === -1) { + return false; + } + const prevChar = index > 0 ? string[index - 1] : null; + // return false if the unexpected character is escaped, true if it is not + return prevChar !== "\\"; +} + +function collectString(token, tokens, index) { + const firstChar = token.value[0]; + const isString = isStringChar(firstChar); + const UNESCAPED_CHAR_ERROR = segment => + `String has unescaped \`${firstChar}\` in [${segment}...],` + + " may miss a space between arguments"; + let value = token.value; + + // the test value is not a string, or it is a string but a complete one + // i.e. `"test"`, as opposed to `"foo`. In either case, this we can return early + if (!isString || checkLastChar(value, firstChar)) { + return { value, offset: 0 }; + } + + if (hasUnescapedChar(value, firstChar, 1, 0)) { + throw Error(UNESCAPED_CHAR_ERROR(value)); + } + + let offset = null; + for (let i = index + 1; i <= tokens.length; i++) { + if (i === tokens.length) { + throw Error("String does not terminate"); + } + + const nextToken = tokens[i]; + if (nextToken.type !== ARG) { + throw Error(`String does not terminate before flag "${nextToken.value}"`); + } + + value = `${value} ${nextToken.value}`; + + if (hasUnescapedChar(nextToken.value, firstChar, 0, 1)) { + throw Error(UNESCAPED_CHAR_ERROR(value)); + } + + if (checkLastChar(nextToken.value, firstChar)) { + offset = i - index; + break; + } + } + return { value, offset }; +} + +function isCommand(string) { + return COMMAND_PREFIX.test(string); +} + +function isKey(string) { + return KEY_PREFIX.test(string); +} + +function getTypedValue(value) { + if (!isNaN(value)) { + return Number(value); + } + if (value === "true" || value === "false") { + return Boolean(value); + } + if (isStringChar(value[0])) { + return value.slice(1, value.length - 1); + } + return value; +} + +exports.formatCommand = formatCommand; +exports.isCommand = isCommand; diff --git a/devtools/server/actors/webconsole/eager-ecma-allowlist.js b/devtools/server/actors/webconsole/eager-ecma-allowlist.js new file mode 100644 index 0000000000..eb0bf7ac0c --- /dev/null +++ b/devtools/server/actors/webconsole/eager-ecma-allowlist.js @@ -0,0 +1,248 @@ +/* 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/. */ +/* global BigInt */ + +"use strict"; + +function matchingProperties(obj, regexp) { + return Object.getOwnPropertyNames(obj) + .filter(n => regexp.test(n)) + .map(n => obj[n]) + .filter(v => typeof v == "function"); +} + +function allProperties(obj) { + return matchingProperties(obj, /./); +} + +function getter(obj, name) { + return Object.getOwnPropertyDescriptor(obj, name).get; +} + +const TypedArray = Reflect.getPrototypeOf(Int8Array); + +const functionAllowList = [ + Array, + Array.from, + Array.isArray, + Array.of, + Array.prototype.concat, + Array.prototype.entries, + Array.prototype.every, + Array.prototype.filter, + Array.prototype.find, + Array.prototype.findIndex, + Array.prototype.flat, + Array.prototype.flatMap, + Array.prototype.forEach, + Array.prototype.includes, + Array.prototype.indexOf, + Array.prototype.join, + Array.prototype.keys, + Array.prototype.lastIndexOf, + Array.prototype.map, + Array.prototype.reduce, + Array.prototype.reduceRight, + Array.prototype.slice, + Array.prototype.some, + Array.prototype.values, + ArrayBuffer, + ArrayBuffer.isView, + ArrayBuffer.prototype.slice, + BigInt, + ...allProperties(BigInt), + Boolean, + DataView, + Date, + Date.now, + Date.parse, + Date.UTC, + ...matchingProperties(Date.prototype, /^get/), + ...matchingProperties(Date.prototype, /^to.*?String$/), + Error, + Function, + Function.prototype.apply, + Function.prototype.bind, + Function.prototype.call, + Function.prototype[Symbol.hasInstance], + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + TypedArray.from, + TypedArray.of, + TypedArray.prototype.entries, + TypedArray.prototype.every, + TypedArray.prototype.filter, + TypedArray.prototype.find, + TypedArray.prototype.findIndex, + TypedArray.prototype.forEach, + TypedArray.prototype.includes, + TypedArray.prototype.indexOf, + TypedArray.prototype.join, + TypedArray.prototype.keys, + TypedArray.prototype.lastIndexOf, + TypedArray.prototype.map, + TypedArray.prototype.reduce, + TypedArray.prototype.reduceRight, + TypedArray.prototype.slice, + TypedArray.prototype.some, + TypedArray.prototype.subarray, + TypedArray.prototype.values, + ...allProperties(JSON), + Map, + Map.prototype.forEach, + Map.prototype.get, + Map.prototype.has, + Map.prototype.entries, + Map.prototype.keys, + Map.prototype.values, + ...allProperties(Math), + Number, + ...allProperties(Number), + ...allProperties(Number.prototype), + Object, + Object.create, + Object.keys, + Object.entries, + Object.getOwnPropertyDescriptor, + Object.getOwnPropertyDescriptors, + Object.getOwnPropertyNames, + Object.getOwnPropertySymbols, + Object.getPrototypeOf, + Object.is, + Object.isExtensible, + Object.isFrozen, + Object.isSealed, + Object.values, + Object.prototype.hasOwnProperty, + Object.prototype.isPrototypeOf, + Proxy, + Proxy.revocable, + Reflect.apply, + Reflect.construct, + Reflect.get, + Reflect.getOwnPropertyDescriptor, + Reflect.getPrototypeOf, + Reflect.has, + Reflect.isExtensible, + Reflect.ownKeys, + RegExp, + RegExp.prototype.exec, + RegExp.prototype.test, + RegExp.prototype[Symbol.match], + RegExp.prototype[Symbol.search], + RegExp.prototype[Symbol.replace], + Set, + Set.prototype.entries, + Set.prototype.forEach, + Set.prototype.has, + Set.prototype.values, + String, + ...allProperties(String), + ...allProperties(String.prototype), + Symbol, + Symbol.keyFor, + WeakMap, + WeakMap.prototype.get, + WeakMap.prototype.has, + WeakSet, + WeakSet.prototype.has, + decodeURI, + decodeURIComponent, + encodeURI, + encodeURIComponent, + escape, + isFinite, + isNaN, + unescape, +]; + +const getterAllowList = [ + getter(ArrayBuffer.prototype, "byteLength"), + getter(ArrayBuffer, Symbol.species), + getter(Array, Symbol.species), + getter(DataView.prototype, "buffer"), + getter(DataView.prototype, "byteLength"), + getter(DataView.prototype, "byteOffset"), + getter(Error.prototype, "stack"), + getter(Function.prototype, "arguments"), + getter(Function.prototype, "caller"), + getter(Intl.Locale.prototype, "baseName"), + getter(Intl.Locale.prototype, "calendar"), + getter(Intl.Locale.prototype, "caseFirst"), + getter(Intl.Locale.prototype, "collation"), + getter(Intl.Locale.prototype, "hourCycle"), + getter(Intl.Locale.prototype, "numeric"), + getter(Intl.Locale.prototype, "numberingSystem"), + getter(Intl.Locale.prototype, "language"), + getter(Intl.Locale.prototype, "script"), + getter(Intl.Locale.prototype, "region"), + getter(Map.prototype, "size"), + getter(Map, Symbol.species), + // NOTE: Object.prototype.__proto__ is not safe, because it can internally + // invoke Proxy getPrototypeOf handler. + getter(Promise, Symbol.species), + getter(RegExp, "input"), + getter(RegExp, "lastMatch"), + getter(RegExp, "lastParen"), + getter(RegExp, "leftContext"), + getter(RegExp, "rightContext"), + getter(RegExp, "$1"), + getter(RegExp, "$2"), + getter(RegExp, "$3"), + getter(RegExp, "$4"), + getter(RegExp, "$5"), + getter(RegExp, "$6"), + getter(RegExp, "$7"), + getter(RegExp, "$8"), + getter(RegExp, "$9"), + getter(RegExp, "$_"), + getter(RegExp, "$&"), + getter(RegExp, "$+"), + getter(RegExp, "$`"), + getter(RegExp, "$'"), + getter(RegExp.prototype, "dotAll"), + getter(RegExp.prototype, "flags"), + getter(RegExp.prototype, "global"), + getter(RegExp.prototype, "hasIndices"), + getter(RegExp.prototype, "ignoreCase"), + getter(RegExp.prototype, "multiline"), + getter(RegExp.prototype, "source"), + getter(RegExp.prototype, "sticky"), + getter(RegExp.prototype, "unicode"), + getter(RegExp, Symbol.species), + getter(Set.prototype, "size"), + getter(Set, Symbol.species), + getter(Symbol.prototype, "description"), + getter(TypedArray.prototype, "buffer"), + getter(TypedArray.prototype, "byteLength"), + getter(TypedArray.prototype, "byteOffset"), + getter(TypedArray.prototype, "length"), + getter(TypedArray.prototype, Symbol.toStringTag), + getter(TypedArray, Symbol.species), +]; + +// TODO: Integrate in main list when changes array by copy ships by default +const changesArrayByCopy = [ + Array.prototype.toReversed, + Array.prototype.toSorted, + Array.prototype.toSpliced, + Array.prototype.with, + TypedArray.prototype.toReversed, + TypedArray.prototype.toSorted, + TypedArray.prototype.with, +]; +for (const fn of changesArrayByCopy) { + if (typeof fn == "function") { + functionAllowList.push(fn); + } +} + +module.exports = { functions: functionAllowList, getters: getterAllowList }; diff --git a/devtools/server/actors/webconsole/eager-function-allowlist.js b/devtools/server/actors/webconsole/eager-function-allowlist.js new file mode 100644 index 0000000000..363591523d --- /dev/null +++ b/devtools/server/actors/webconsole/eager-function-allowlist.js @@ -0,0 +1,52 @@ +/* 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 idlPureAllowlist = require("resource://devtools/server/actors/webconsole/webidl-pure-allowlist.js"); + +const natives = []; +if (Components.Constructor && Cu) { + const sandbox = Cu.Sandbox( + Components.Constructor("@mozilla.org/systemprincipal;1", "nsIPrincipal")(), + { + invisibleToDebugger: true, + wantGlobalProperties: Object.keys(idlPureAllowlist), + } + ); + + function maybePush(maybeFunc) { + if (maybeFunc) { + natives.push(maybeFunc); + } + } + + function collectMethods(obj, methods) { + for (const name of methods) { + maybePush(obj[name]); + } + } + + for (const [iface, ifaceData] of Object.entries(idlPureAllowlist)) { + const ctor = sandbox[iface]; + if (!ctor) { + continue; + } + + if ("static" in ifaceData) { + collectMethods(ctor, ifaceData.static); + } + + if ("prototype" in ifaceData) { + const proto = ctor.prototype; + if (!proto) { + continue; + } + + collectMethods(proto, ifaceData.prototype); + } + } +} + +module.exports = { natives }; diff --git a/devtools/server/actors/webconsole/eval-with-debugger.js b/devtools/server/actors/webconsole/eval-with-debugger.js new file mode 100644 index 0000000000..3d2c4fae49 --- /dev/null +++ b/devtools/server/actors/webconsole/eval-with-debugger.js @@ -0,0 +1,618 @@ +/* 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 Debugger = require("Debugger"); +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + Reflect: "resource://gre/modules/reflect.sys.mjs", +}); +loader.lazyRequireGetter( + this, + ["formatCommand", "isCommand"], + "resource://devtools/server/actors/webconsole/commands/parser.js", + true +); +loader.lazyRequireGetter( + this, + "WebConsoleCommandsManager", + "resource://devtools/server/actors/webconsole/commands/manager.js", + true +); + +loader.lazyRequireGetter( + this, + "LongStringActor", + "resource://devtools/server/actors/string.js", + true +); +loader.lazyRequireGetter( + this, + "eagerEcmaAllowlist", + "resource://devtools/server/actors/webconsole/eager-ecma-allowlist.js" +); +loader.lazyRequireGetter( + this, + "eagerFunctionAllowlist", + "resource://devtools/server/actors/webconsole/eager-function-allowlist.js" +); + +function isObject(value) { + return Object(value) === value; +} + +/** + * Evaluates a string using the debugger API. + * + * To allow the variables view to update properties from the Web Console we + * provide the "selectedObjectActor" mechanism: the Web Console tells the + * ObjectActor ID for which it desires to evaluate an expression. The + * Debugger.Object pointed at by the actor ID is bound such that it is + * available during expression evaluation (executeInGlobalWithBindings()). + * + * Example: + * _self['foobar'] = 'test' + * where |_self| refers to the desired object. + * + * The |frameActor| property allows the Web Console client to provide the + * frame actor ID, such that the expression can be evaluated in the + * user-selected stack frame. + * + * For the above to work we need the debugger and the Web Console to share + * a connection, otherwise the Web Console actor will not find the frame + * actor. + * + * The Debugger.Frame comes from the jsdebugger's Debugger instance, which + * is different from the Web Console's Debugger instance. This means that + * for evaluation to work, we need to create a new instance for the Web + * Console Commands helpers - they need to be Debugger.Objects coming from the + * jsdebugger's Debugger instance. + * + * When |selectedObjectActor| is used objects can come from different iframes, + * from different domains. To avoid permission-related errors when objects + * come from a different window, we also determine the object's own global, + * such that evaluation happens in the context of that global. This means that + * evaluation will happen in the object's iframe, rather than the top level + * window. + * + * @param string string + * String to evaluate. + * @param object [options] + * Options for evaluation: + * - selectedObjectActor: the ObjectActor ID to use for evaluation. + * |evalWithBindings()| will be called with one additional binding: + * |_self| which will point to the Debugger.Object of the given + * ObjectActor. Executes with the top level window as the global. + * - frameActor: the FrameActor ID to use for evaluation. The given + * debugger frame is used for evaluation, instead of the global window. + * - selectedNodeActor: the NodeActor ID of the currently selected node + * in the Inspector (or null, if there is no selection). This is used + * for helper functions that make reference to the currently selected + * node, like $0. + * - innerWindowID: An optional window id to use instead of webConsole.evalWindow. + * This is used by function that need to evaluate in a different window for which + * we don't have a dedicated target (for example a non-remote iframe). + * - eager: Set to true if you want the evaluation to bail if it may have side effects. + * - url: the url to evaluate the script as. Defaults to "debugger eval code", + * or "debugger eager eval code" if eager is true. + * @param object webConsole + * + * @return object + * An object that holds the following properties: + * - dbg: the debugger where the string was evaluated. + * - frame: (optional) the frame where the string was evaluated. + * - global: the Debugger.Object for the global where the string was evaluated in. + * - result: the result of the evaluation. + */ +exports.evalWithDebugger = function (string, options = {}, webConsole) { + if (isCommand(string.trim()) && options.eager) { + return { + result: null, + }; + } + + const evalString = getEvalInput(string); + const { frame, dbg } = getFrameDbg(options, webConsole); + + const { dbgGlobal, bindSelf } = getDbgGlobal(options, dbg, webConsole); + + const helpers = WebConsoleCommandsManager.getWebConsoleCommands( + webConsole, + dbgGlobal, + frame, + string, + options.selectedNodeActor + ); + let { bindings } = helpers; + + // '_self' refers to the JS object references via options.selectedObjectActor. + // This isn't exposed on typical console evaluation, but only when "Store As Global" + // runs an invisible script storing `_self` into `temp${i}`. + if (bindSelf) { + bindings._self = bindSelf; + } + + // Log points calls this method from the server side and pass additional variables + // to be exposed to the evaluated JS string + if (options.bindings) { + bindings = { ...bindings, ...options.bindings }; + } + + const evalOptions = {}; + + const urlOption = + options.url || (options.eager ? "debugger eager eval code" : null); + if (typeof urlOption === "string") { + evalOptions.url = urlOption; + } + + if (typeof options.lineNumber === "number") { + evalOptions.lineNumber = options.lineNumber; + } + + updateConsoleInputEvaluation(dbg, webConsole); + + let noSideEffectDebugger = null; + if (options.eager) { + noSideEffectDebugger = makeSideeffectFreeDebugger(); + } + + let result; + try { + result = getEvalResult( + dbg, + evalString, + evalOptions, + bindings, + frame, + dbgGlobal, + noSideEffectDebugger + ); + } finally { + // We need to be absolutely sure that the sideeffect-free debugger's + // debuggees are removed because otherwise we risk them terminating + // execution of later code in the case of unexpected exceptions. + if (noSideEffectDebugger) { + noSideEffectDebugger.removeAllDebuggees(); + } + } + + // Attempt to initialize any declarations found in the evaluated string + // since they may now be stuck in an "initializing" state due to the + // error. Already-initialized bindings will be ignored. + if (!frame && result && "throw" in result) { + forceLexicalInitForVariableDeclarationsInThrowingExpression( + dbgGlobal, + string + ); + } + + return { + result, + // Retrieve the result of commands, if any ran + helperResult: helpers.getHelperResult(), + dbg, + frame, + dbgGlobal, + }; +}; + +function getEvalResult( + dbg, + string, + evalOptions, + bindings, + frame, + dbgGlobal, + noSideEffectDebugger +) { + if (noSideEffectDebugger) { + // Bug 1637883 demonstrated an issue where dbgGlobal was somehow in the + // same compartment as the Debugger, meaning it could not be debugged + // and thus cannot handle eager evaluation. In that case we skip execution. + if (!noSideEffectDebugger.hasDebuggee(dbgGlobal.unsafeDereference())) { + return null; + } + + // When a sideeffect-free debugger has been created, we need to eval + // in the context of that debugger in order for the side-effect tracking + // to apply. + frame = frame ? noSideEffectDebugger.adoptFrame(frame) : null; + dbgGlobal = noSideEffectDebugger.adoptDebuggeeValue(dbgGlobal); + if (bindings) { + bindings = Object.keys(bindings).reduce((acc, key) => { + acc[key] = noSideEffectDebugger.adoptDebuggeeValue(bindings[key]); + return acc; + }, {}); + } + } + + let result; + if (frame) { + result = frame.evalWithBindings(string, bindings, evalOptions); + } else { + result = dbgGlobal.executeInGlobalWithBindings( + string, + bindings, + evalOptions + ); + } + if (noSideEffectDebugger && result) { + if ("return" in result) { + result.return = dbg.adoptDebuggeeValue(result.return); + } + if ("throw" in result) { + result.throw = dbg.adoptDebuggeeValue(result.throw); + } + } + return result; +} + +/** + * Force lexical initialization for let/const variables declared in a throwing expression. + * By spec, a lexical declaration is added to the *page-visible* global lexical environment + * for those variables, meaning they can't be redeclared (See Bug 1246215). + * + * This function gets the AST of the throwing expression to collect all the let/const + * declarations and call `forceLexicalInitializationByName`, which will initialize them + * to undefined, making it possible for them to be redeclared. + * + * @param {DebuggerObject} dbgGlobal + * @param {String} string: The expression that was evaluated and threw + * @returns + */ +function forceLexicalInitForVariableDeclarationsInThrowingExpression( + dbgGlobal, + string +) { + // Reflect is not usable in workers, so return early to avoid logging an error + // to the console when loading it. + if (isWorker) { + return; + } + + let ast; + // Parse errors will raise an exception. We can/should ignore the error + // since it's already being handled elsewhere and we are only interested + // in initializing bindings. + try { + ast = lazy.Reflect.parse(string); + } catch (e) { + return; + } + + try { + for (const line of ast.body) { + // Only let and const declarations put bindings into an + // "initializing" state. + if (!(line.kind == "let" || line.kind == "const")) { + continue; + } + + const identifiers = []; + for (const decl of line.declarations) { + switch (decl.id.type) { + case "Identifier": + // let foo = bar; + identifiers.push(decl.id.name); + break; + case "ArrayPattern": + // let [foo, bar] = [1, 2]; + // let [foo=99, bar] = [1, 2]; + for (const e of decl.id.elements) { + if (e.type == "Identifier") { + identifiers.push(e.name); + } else if (e.type == "AssignmentExpression") { + identifiers.push(e.left.name); + } + } + break; + case "ObjectPattern": + // let {bilbo, my} = {bilbo: "baggins", my: "precious"}; + // let {blah: foo} = {blah: yabba()} + // let {blah: foo=99} = {blah: yabba()} + for (const prop of decl.id.properties) { + // key + if (prop.key?.type == "Identifier") { + identifiers.push(prop.key.name); + } + // value + if (prop.value?.type == "Identifier") { + identifiers.push(prop.value.name); + } else if (prop.value?.type == "AssignmentExpression") { + identifiers.push(prop.value.left.name); + } else if (prop.type === "SpreadExpression") { + identifiers.push(prop.expression.name); + } + } + break; + } + } + + for (const name of identifiers) { + dbgGlobal.forceLexicalInitializationByName(name); + } + } + } catch (ex) { + console.error( + "Error in forceLexicalInitForVariableDeclarationsInThrowingExpression:", + ex + ); + } +} + +/** + * Creates a side-effect-free debugger instance + * + * @return object + * Side-effect-free debugger. + */ +function makeSideeffectFreeDebugger() { + // We ensure that the metadata for native functions is loaded before we + // initialize sideeffect-prevention because the data is lazy-loaded, and this + // logic can run inside of debuggee compartments because the + // "addAllGlobalsAsDebuggees" considers the vast majority of realms + // valid debuggees. Without this, eager-eval runs the risk of failing + // because building the list of valid native functions is itself a + // side-effectful operation because it needs to populate a + // module cache, among any number of other things. + ensureSideEffectFreeNatives(); + + // Note: It is critical for debuggee performance that we implement all of + // this debuggee tracking logic with a separate Debugger instance. + // Bug 1617666 arises otherwise if we set an onEnterFrame hook on the + // existing debugger object and then later clear it. + const dbg = new Debugger(); + dbg.addAllGlobalsAsDebuggees(); + + const timeoutDuration = 100; + const endTime = Date.now() + timeoutDuration; + let count = 0; + function shouldCancel() { + // To keep the evaled code as quick as possible, we avoid querying the + // current time on ever single step and instead check every 100 steps + // as an arbitrary count that seemed to be "often enough". + return ++count % 100 === 0 && Date.now() > endTime; + } + + const executedScripts = new Set(); + const handler = { + hit: () => null, + }; + dbg.onEnterFrame = frame => { + if (shouldCancel()) { + return null; + } + frame.onStep = () => { + if (shouldCancel()) { + return null; + } + return undefined; + }; + + const script = frame.script; + + if (executedScripts.has(script)) { + return undefined; + } + executedScripts.add(script); + + const offsets = script.getEffectfulOffsets(); + for (const offset of offsets) { + script.setBreakpoint(offset, handler); + } + + return undefined; + }; + + // The debugger only calls onNativeCall handlers on the debugger that is + // explicitly calling either eval, DebuggerObject.apply or DebuggerObject.call, + // so we need to add this hook on "dbg" even though the rest of our hooks work via "newDbg". + dbg.onNativeCall = (callee, reason) => { + try { + // Setters are always effectful. Natives called normally or called via + // getters are handled with an allowlist. + if ( + (reason == "get" || reason == "call") && + nativeIsEagerlyEvaluateable(callee) + ) { + // Returning undefined causes execution to continue normally. + return undefined; + } + } catch (err) { + DevToolsUtils.reportException( + "evalWithDebugger onNativeCall", + new Error("Unable to validate native function against allowlist") + ); + } + // Returning null terminates the current evaluation. + return null; + }; + + return dbg; +} + +// Native functions which are considered to be side effect free. +let gSideEffectFreeNatives; // string => Array(Function) + +/** + * Generate gSideEffectFreeNatives map. + */ +function ensureSideEffectFreeNatives() { + if (gSideEffectFreeNatives) { + return; + } + + const { natives: domNatives } = eagerFunctionAllowlist; + + const natives = [ + ...eagerEcmaAllowlist.functions, + ...eagerEcmaAllowlist.getters, + + // Pull in all of the non-ECMAScript native functions that we want to + // allow as well. + ...domNatives, + ]; + + const map = new Map(); + for (const n of natives) { + if (!map.has(n.name)) { + map.set(n.name, []); + } + map.get(n.name).push(n); + } + + gSideEffectFreeNatives = map; +} + +function nativeIsEagerlyEvaluateable(fn) { + if (fn.isBoundFunction) { + fn = fn.boundTargetFunction; + } + + // We assume all DOM getters have no major side effect, and they are + // eagerly-evaluateable. + // + // JitInfo is used only by methods/accessors in WebIDL, and being + // "a getter with JitInfo" can be used as a condition to check if given + // function is DOM getter. + // + // This includes privileged interfaces in addition to standard web APIs. + if (fn.isNativeGetterWithJitInfo()) { + return true; + } + + // Natives with certain names are always considered side effect free. + switch (fn.name) { + case "toString": + case "toLocaleString": + case "valueOf": + return true; + } + + const natives = gSideEffectFreeNatives.get(fn.name); + return natives && natives.some(n => fn.isSameNative(n)); +} + +function updateConsoleInputEvaluation(dbg, webConsole) { + // Adopt webConsole._lastConsoleInputEvaluation value in the new debugger, + // to prevent "Debugger.Object belongs to a different Debugger" exceptions + // related to the $_ bindings if the debugger object is changed from the + // last evaluation. + if (webConsole._lastConsoleInputEvaluation) { + webConsole._lastConsoleInputEvaluation = dbg.adoptDebuggeeValue( + webConsole._lastConsoleInputEvaluation + ); + } +} + +function getEvalInput(string) { + const trimmedString = string.trim(); + // The help function needs to be easy to guess, so we make the () optional. + if (trimmedString === "help" || trimmedString === "?") { + return "help()"; + } + // we support Unix like syntax for commands if it is preceeded by `:` + if (isCommand(string)) { + try { + return formatCommand(string); + } catch (e) { + console.log(e); + return `throw "${e}"`; + } + } + + // Add easter egg for console.mihai(). + if ( + trimmedString == "console.mihai()" || + trimmedString == "console.mihai();" + ) { + return '"http://incompleteness.me/blog/2015/02/09/console-dot-mihai/"'; + } + return string; +} + +function getFrameDbg(options, webConsole) { + if (!options.frameActor) { + return { frame: null, dbg: webConsole.dbg }; + } + // Find the Debugger.Frame of the given FrameActor. + const frameActor = webConsole.conn.getActor(options.frameActor); + if (frameActor) { + // If we've been given a frame actor in whose scope we should evaluate the + // expression, be sure to use that frame's Debugger (that is, the JavaScript + // debugger's Debugger) for the whole operation, not the console's Debugger. + // (One Debugger will treat a different Debugger's Debugger.Object instances + // as ordinary objects, not as references to be followed, so mixing + // debuggers causes strange behaviors.) + return { frame: frameActor.frame, dbg: frameActor.threadActor.dbg }; + } + return DevToolsUtils.reportException( + "evalWithDebugger", + Error("The frame actor was not found: " + options.frameActor) + ); +} + +/** + * Get debugger object for given debugger and Web Console. + * + * @param object options + * See the `options` parameter of evalWithDebugger + * @param {Debugger} dbg + * Debugger object + * @param {WebConsoleActor} webConsole + * A reference to a webconsole actor which is used to get the target + * eval global and optionally the target actor + * @return object + * An object that holds the following properties: + * - bindSelf: (optional) the self object for the evaluation + * - dbgGlobal: the global object reference in the debugger + */ +function getDbgGlobal(options, dbg, webConsole) { + let evalGlobal = webConsole.evalGlobal; + + if (options.innerWindowID) { + const window = Services.wm.getCurrentInnerWindowWithId( + options.innerWindowID + ); + + if (window) { + evalGlobal = window; + } + } + + const dbgGlobal = dbg.makeGlobalObjectReference(evalGlobal); + + // If we have an object to bind to |_self|, create a Debugger.Object + // referring to that object, belonging to dbg. + if (!options.selectedObjectActor) { + return { bindSelf: null, dbgGlobal }; + } + + // For objects related to console messages, they will be registered under the Target Actor + // instead of the WebConsoleActor. That's because console messages are resources and all resources + // are emitted by the Target Actor. + const actor = + webConsole.getActorByID(options.selectedObjectActor) || + webConsole.parentActor.getActorByID(options.selectedObjectActor); + + if (!actor) { + return { bindSelf: null, dbgGlobal }; + } + + const jsVal = actor instanceof LongStringActor ? actor.str : actor.rawValue(); + if (!isObject(jsVal)) { + return { bindSelf: jsVal, dbgGlobal }; + } + + // If we use the makeDebuggeeValue method of jsVal's own global, then + // we'll get a D.O that sees jsVal as viewed from its own compartment - + // that is, without wrappers. The evalWithBindings call will then wrap + // jsVal appropriately for the evaluation compartment. + const bindSelf = dbgGlobal.makeDebuggeeValue(jsVal); + return { bindSelf, dbgGlobal }; +} diff --git a/devtools/server/actors/webconsole/listeners/console-api.js b/devtools/server/actors/webconsole/listeners/console-api.js new file mode 100644 index 0000000000..3e5d0bc52f --- /dev/null +++ b/devtools/server/actors/webconsole/listeners/console-api.js @@ -0,0 +1,255 @@ +/* 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 { + CONSOLE_WORKER_IDS, + WebConsoleUtils, +} = require("resource://devtools/server/actors/webconsole/utils.js"); + +// The window.console API observer + +/** + * The window.console API observer. This allows the window.console API messages + * to be sent to the remote Web Console instance. + * + * @constructor + * @param nsIDOMWindow window + * Optional - the window object for which we are created. This is used + * for filtering out messages that belong to other windows. + * @param Function handler + * This function is invoked with one argument, the Console API message that comes + * from the observer service, whenever a relevant console API call is received. + * @param object filteringOptions + * Optional - The filteringOptions that this listener should listen to: + * - addonId: filter console messages based on the addonId. + * - excludeMessagesBoundToWindow: Set to true to filter out messages that + * are bound to a specific window. + * - matchExactWindow: Set to true to match the messages on a specific window (when + * `window` is defined) and not on the whole window tree. + */ +class ConsoleAPIListener { + constructor( + window, + handler, + { addonId, excludeMessagesBoundToWindow, matchExactWindow } = {} + ) { + this.window = window; + this.handler = handler; + this.addonId = addonId; + this.excludeMessagesBoundToWindow = excludeMessagesBoundToWindow; + this.matchExactWindow = matchExactWindow; + if (this.window) { + this.innerWindowId = WebConsoleUtils.getInnerWindowId(this.window); + } + } + + QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver]); + + /** + * The content window for which we listen to window.console API calls. + * @type nsIDOMWindow + */ + window = null; + + /** + * The function which is notified of window.console API calls. It is invoked with one + * argument: the console API call object that comes from the ConsoleAPIStorage service. + * + * @type function + */ + handler = null; + + /** + * The addonId that we listen for. If not null then only messages from this + * console will be returned. + */ + addonId = null; + + /** + * Initialize the window.console API listener. + */ + init() { + const ConsoleAPIStorage = Cc[ + "@mozilla.org/consoleAPI-storage;1" + ].getService(Ci.nsIConsoleAPIStorage); + + // Note that the listener is process-wide. We will filter the messages as + // needed, see onConsoleAPILogEvent(). + this.onConsoleAPILogEvent = this.onConsoleAPILogEvent.bind(this); + ConsoleAPIStorage.addLogEventListener( + this.onConsoleAPILogEvent, + // We create a principal here to get the privileged principal of this + // script. Note that this is importantly *NOT* the principal of the + // content we are observing, as that would not have access to the + // message object created in ConsoleAPIStorage.jsm's scope. + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + } + + /** + * The console API message listener. When messages are received from the + * ConsoleAPIStorage service we forward them to the remote Web Console instance. + * + * @param object message + * The message object receives from the ConsoleAPIStorage service. + */ + onConsoleAPILogEvent(message) { + if (!this.handler) { + return; + } + + // Here, wrappedJSObject is not a security wrapper but a property defined + // by the XPCOM component which allows us to unwrap the XPCOM interface and + // access the underlying JSObject. + const apiMessage = message.wrappedJSObject; + + if (!this.isMessageRelevant(apiMessage)) { + return; + } + + this.handler(apiMessage); + } + + /** + * Given a message, return true if this window should show it and false + * if it should be ignored. + * + * @param message + * The message from the Storage Service + * @return bool + * Do we care about this message? + */ + isMessageRelevant(message) { + const workerType = WebConsoleUtils.getWorkerType(message); + + if (this.window && workerType === "ServiceWorker") { + // For messages from Service Workers, message.ID is the + // scope, which can be used to determine whether it's controlling + // a window. + const scope = message.ID; + + if (!this.window.shouldReportForServiceWorkerScope(scope)) { + return false; + } + } + + // innerID can be of different type: + // - a number if the message is bound to a specific window + // - a worker type ([Shared|Service]Worker) if the message comes from a worker + // - a JSM filename + // if we want to filter on a specific window, ignore all non-worker messages that + // don't have a proper window id (for now, we receive the worker messages from the + // main process so we still want to get them, although their innerID isn't a number). + if (!workerType && typeof message.innerID !== "number" && this.window) { + return false; + } + + // Don't show ChromeWorker messages on WindowGlobal targets + if (workerType && this.window && message.chromeContext) { + return false; + } + + if (typeof message.innerID == "number") { + if ( + this.excludeMessagesBoundToWindow && + // If innerID is 0, the message isn't actually bound to a window. + message.innerID + ) { + return false; + } + + if (this.window) { + const matchesWindow = this.matchExactWindow + ? this.innerWindowId === message.innerID + : WebConsoleUtils.getInnerWindowIDsForFrames(this.window).includes( + message.innerID + ); + + if (!matchesWindow) { + // Not the same window! + return false; + } + } + } + + if (this.addonId) { + // ConsoleAPI.jsm messages contains a consoleID, (and it is currently + // used in Addon SDK add-ons), the standard 'console' object + // (which is used in regular webpages and in WebExtensions pages) + // contains the originAttributes of the source document principal. + + // Filtering based on the originAttributes used by + // the Console API object. + if (message.addonId == this.addonId) { + return true; + } + + // Filtering based on the old-style consoleID property used by + // the legacy Console JSM module. + if (message.consoleID && message.consoleID == `addon/${this.addonId}`) { + return true; + } + + return false; + } + + return true; + } + + /** + * Get the cached messages for the current inner window and its (i)frames. + * + * @param boolean [includePrivate=false] + * Tells if you want to also retrieve messages coming from private + * windows. Defaults to false. + * @return array + * The array of cached messages. + */ + getCachedMessages(includePrivate = false) { + let messages = []; + const ConsoleAPIStorage = Cc[ + "@mozilla.org/consoleAPI-storage;1" + ].getService(Ci.nsIConsoleAPIStorage); + + // if !this.window, we're in a browser console. Retrieve all events + // for filtering based on privacy. + if (!this.window) { + messages = ConsoleAPIStorage.getEvents(); + } else if (this.matchExactWindow) { + messages = ConsoleAPIStorage.getEvents(this.innerWindowId); + } else { + WebConsoleUtils.getInnerWindowIDsForFrames(this.window).forEach(id => { + messages = messages.concat(ConsoleAPIStorage.getEvents(id)); + }); + } + + CONSOLE_WORKER_IDS.forEach(id => { + messages = messages.concat(ConsoleAPIStorage.getEvents(id)); + }); + + messages = messages.filter(msg => { + return this.isMessageRelevant(msg); + }); + + if (includePrivate) { + return messages; + } + + return messages.filter(m => !m.private); + } + + /** + * Destroy the console API listener. + */ + destroy() { + const ConsoleAPIStorage = Cc[ + "@mozilla.org/consoleAPI-storage;1" + ].getService(Ci.nsIConsoleAPIStorage); + ConsoleAPIStorage.removeLogEventListener(this.onConsoleAPILogEvent); + this.window = this.handler = null; + } +} +exports.ConsoleAPIListener = ConsoleAPIListener; diff --git a/devtools/server/actors/webconsole/listeners/console-file-activity.js b/devtools/server/actors/webconsole/listeners/console-file-activity.js new file mode 100644 index 0000000000..7e5ae0d1a8 --- /dev/null +++ b/devtools/server/actors/webconsole/listeners/console-file-activity.js @@ -0,0 +1,126 @@ +/* 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"; + +/** + * A WebProgressListener that listens for file loads. + * + * @constructor + * @param object window + * The window for which we need to track file loads. + * @param object owner + * The listener owner which needs to implement: + * - onFileActivity(aFileURI) + */ +function ConsoleFileActivityListener(window, owner) { + this.window = window; + this.owner = owner; +} +exports.ConsoleFileActivityListener = ConsoleFileActivityListener; + +ConsoleFileActivityListener.prototype = { + /** + * Tells if the console progress listener is initialized or not. + * @private + * @type boolean + */ + _initialized: false, + + _webProgress: null, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + /** + * Initialize the ConsoleFileActivityListener. + * @private + */ + _init() { + if (this._initialized) { + return; + } + + this._webProgress = this.window.docShell.QueryInterface(Ci.nsIWebProgress); + this._webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + + this._initialized = true; + }, + + /** + * Start a monitor/tracker related to the current nsIWebProgressListener + * instance. + */ + startMonitor() { + this._init(); + }, + + /** + * Stop monitoring. + */ + stopMonitor() { + this.destroy(); + }, + + onStateChange(progress, request, state, status) { + if (!this.owner) { + return; + } + + this._checkFileActivity(progress, request, state, status); + }, + + /** + * Check if there is any file load, given the arguments of + * nsIWebProgressListener.onStateChange. If the state change tells that a file + * URI has been loaded, then the remote Web Console instance is notified. + * @private + */ + _checkFileActivity(progress, request, state, status) { + if (!(state & Ci.nsIWebProgressListener.STATE_START)) { + return; + } + + let uri = null; + if (request instanceof Ci.imgIRequest) { + const imgIRequest = request.QueryInterface(Ci.imgIRequest); + uri = imgIRequest.URI; + } else if (request instanceof Ci.nsIChannel) { + const nsIChannel = request.QueryInterface(Ci.nsIChannel); + uri = nsIChannel.URI; + } + + if (!uri || (!uri.schemeIs("file") && !uri.schemeIs("ftp"))) { + return; + } + + this.owner.onFileActivity(uri.spec); + }, + + /** + * Destroy the ConsoleFileActivityListener. + */ + destroy() { + if (!this._initialized) { + return; + } + + this._initialized = false; + + try { + this._webProgress.removeProgressListener(this); + } catch (ex) { + // This can throw during browser shutdown. + } + + this._webProgress = null; + this.window = null; + this.owner = null; + }, +}; diff --git a/devtools/server/actors/webconsole/listeners/console-reflow.js b/devtools/server/actors/webconsole/listeners/console-reflow.js new file mode 100644 index 0000000000..8404a70b4a --- /dev/null +++ b/devtools/server/actors/webconsole/listeners/console-reflow.js @@ -0,0 +1,90 @@ +/* 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"; + +/** + * A ReflowObserver that listens for reflow events from the page. + * Implements nsIReflowObserver. + * + * @constructor + * @param object window + * The window for which we need to track reflow. + * @param object owner + * The listener owner which needs to implement: + * - onReflowActivity(reflowInfo) + */ + +function ConsoleReflowListener(window, listener) { + this.docshell = window.docShell; + this.listener = listener; + this.docshell.addWeakReflowObserver(this); +} + +exports.ConsoleReflowListener = ConsoleReflowListener; + +ConsoleReflowListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIReflowObserver", + "nsISupportsWeakReference", + ]), + docshell: null, + listener: null, + + /** + * Forward reflow event to listener. + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + * @param boolean interruptible + */ + sendReflow(start, end, interruptible) { + const frame = Components.stack.caller.caller; + + let filename = frame ? frame.filename : null; + + if (filename) { + // Because filename could be of the form "xxx.js -> xxx.js -> xxx.js", + // we only take the last part. + filename = filename.split(" ").pop(); + } + + this.listener.onReflowActivity({ + interruptible, + start, + end, + sourceURL: filename, + sourceLine: frame ? frame.lineNumber : null, + functionName: frame ? frame.name : null, + }); + }, + + /** + * On uninterruptible reflow + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + */ + reflow(start, end) { + this.sendReflow(start, end, false); + }, + + /** + * On interruptible reflow + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + */ + reflowInterruptible(start, end) { + this.sendReflow(start, end, true); + }, + + /** + * Unregister listener. + */ + destroy() { + this.docshell.removeWeakReflowObserver(this); + this.listener = this.docshell = null; + }, +}; diff --git a/devtools/server/actors/webconsole/listeners/console-service.js b/devtools/server/actors/webconsole/listeners/console-service.js new file mode 100644 index 0000000000..11ced5611f --- /dev/null +++ b/devtools/server/actors/webconsole/listeners/console-service.js @@ -0,0 +1,193 @@ +/* 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 { + isWindowIncluded, +} = require("resource://devtools/shared/layout/utils.js"); +const { + WebConsoleUtils, +} = require("resource://devtools/server/actors/webconsole/utils.js"); + +// The page errors listener + +/** + * The nsIConsoleService listener. This is used to send all of the console + * messages (JavaScript, CSS and more) to the remote Web Console instance. + * + * @constructor + * @param nsIDOMWindow [window] + * Optional - the window object for which we are created. This is used + * for filtering out messages that belong to other windows. + * @param Function handler + * This function is invoked with one argument, the nsIConsoleMessage, whenever a + * relevant message is received. + * @param object filteringOptions + * Optional - The filteringOptions that this listener should listen to: + * - matchExactWindow: Set to true to match the messages on a specific window (when + * `window` is defined) and not on the whole window tree. + */ +class ConsoleServiceListener { + constructor(window, handler, { matchExactWindow } = {}) { + this.window = window; + this.handler = handler; + this.matchExactWindow = matchExactWindow; + } + + QueryInterface = ChromeUtils.generateQI([Ci.nsIConsoleListener]); + + /** + * The content window for which we listen to page errors. + * @type nsIDOMWindow + */ + window = null; + + /** + * The function which is notified of messages from the console service. + * @type function + */ + handler = null; + + /** + * Initialize the nsIConsoleService listener. + */ + init() { + Services.console.registerListener(this); + } + + /** + * The nsIConsoleService observer. This method takes all the script error + * messages belonging to the current window and sends them to the remote Web + * Console instance. + * + * @param nsIConsoleMessage message + * The message object coming from the nsIConsoleService. + */ + observe(message) { + if (!this.handler) { + return; + } + + if (this.window) { + if ( + !(message instanceof Ci.nsIScriptError) || + !message.outerWindowID || + !this.isCategoryAllowed(message.category) + ) { + return; + } + + const errorWindow = Services.wm.getOuterWindowWithId( + message.outerWindowID + ); + + if (!errorWindow) { + return; + } + + if (this.matchExactWindow && this.window !== errorWindow) { + return; + } + + if (!isWindowIncluded(this.window, errorWindow)) { + return; + } + } + + // Don't display messages triggered by eager evaluation. + if (message.sourceName === "debugger eager eval code") { + return; + } + this.handler(message); + } + + /** + * Check if the given message category is allowed to be tracked or not. + * We ignore chrome-originating errors as we only care about content. + * + * @param string category + * The message category you want to check. + * @return boolean + * True if the category is allowed to be logged, false otherwise. + */ + isCategoryAllowed(category) { + if (!category) { + return false; + } + + switch (category) { + case "XPConnect JavaScript": + case "component javascript": + case "chrome javascript": + case "chrome registration": + return false; + } + + return true; + } + + /** + * Get the cached page errors for the current inner window and its (i)frames. + * + * @param boolean [includePrivate=false] + * Tells if you want to also retrieve messages coming from private + * windows. Defaults to false. + * @return array + * The array of cached messages. Each element is an nsIScriptError or + * an nsIConsoleMessage + */ + getCachedMessages(includePrivate = false) { + const errors = Services.console.getMessageArray() || []; + + // if !this.window, we're in a browser console. Still need to filter + // private messages. + if (!this.window) { + return errors.filter(error => { + if (error instanceof Ci.nsIScriptError) { + if (!includePrivate && error.isFromPrivateWindow) { + return false; + } + } + + return true; + }); + } + + const ids = this.matchExactWindow + ? [WebConsoleUtils.getInnerWindowId(this.window)] + : WebConsoleUtils.getInnerWindowIDsForFrames(this.window); + + return errors.filter(error => { + if (error instanceof Ci.nsIScriptError) { + if (!includePrivate && error.isFromPrivateWindow) { + return false; + } + if ( + ids && + (!ids.includes(error.innerWindowID) || + !this.isCategoryAllowed(error.category)) + ) { + return false; + } + } else if (ids?.[0]) { + // If this is not an nsIScriptError and we need to do window-based + // filtering we skip this message. + return false; + } + + return true; + }); + } + + /** + * Remove the nsIConsoleService listener. + */ + destroy() { + Services.console.unregisterListener(this); + this.handler = this.window = null; + } +} + +exports.ConsoleServiceListener = ConsoleServiceListener; diff --git a/devtools/server/actors/webconsole/listeners/document-events.js b/devtools/server/actors/webconsole/listeners/document-events.js new file mode 100644 index 0000000000..1c1f926436 --- /dev/null +++ b/devtools/server/actors/webconsole/listeners/document-events.js @@ -0,0 +1,247 @@ +/* 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/. */ + +// XPCNativeWrapper is not defined globally in ESLint as it may be going away. +// See bug 1481337. +/* global XPCNativeWrapper */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * About "navigationStart - ${WILL_NAVIGATE_TIME_SHIFT}ms": + * Unfortunately dom-loading's navigationStart timestamp is older than the navigationStart we receive from will-navigate. + * + * That's because we record `navigationStart` before will-navigate code is called. + * And will-navigate code don't have access to performance.timing.navigationStart that dom-loading is using. + * The `performance.timing.navigationStart` is recorded earlier from `DocumentLoadListener.SetNavigating`, here: + * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#907-908 + * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#820-823 + * While this function is being called via `nsIWebProgressListener.onStateChange`, here: + * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#934-939 + * And we record the navigationStart timestamp from onStateChange by using Date.now(), which is more recent + * than performance.timing.navigationStart. + * + * We do this workaround because all DOCUMENT_EVENT comes with a "time" timestamp. + * Each event relates to a particular event in the lifecycle of documents and are supposed to follow a particular order: + * - will-navigate (on the previous target) + * - dom-loading (on the new target) + * - dom-interactive + * - dom-complete + * And some tests are asserting this. + */ +const WILL_NAVIGATE_TIME_SHIFT = 20; +exports.WILL_NAVIGATE_TIME_SHIFT = WILL_NAVIGATE_TIME_SHIFT; + +/** + * Forward `DOMContentLoaded` and `load` events with precise timing + * of when events happened according to window.performance numbers. + * + * @constructor + * @param WindowGlobalTarget targetActor + */ +function DocumentEventsListener(targetActor) { + this.targetActor = targetActor; + + EventEmitter.decorate(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + this.onWindowReady = this.onWindowReady.bind(this); + this.onContentLoaded = this.onContentLoaded.bind(this); + this.onLoad = this.onLoad.bind(this); +} + +exports.DocumentEventsListener = DocumentEventsListener; + +DocumentEventsListener.prototype = { + listen() { + // When EFT is enabled, the Target Actor won't dispatch any will-navigate/window-ready event + // Instead listen to WebProgressListener interface directly, so that we can later drop the whole + // DebuggerProgressListener interface in favor of this class. + // Also, do not wait for "load" event as it can be blocked in case of error during the load + // or when calling window.stop(). We still want to emit "dom-complete" in these scenarios. + if (this.targetActor.ignoreSubFrames) { + // Ignore listening to anything if the page is already fully loaded. + // This can be the case when opening DevTools against an already loaded page + // or when doing bfcache navigations. + if (this.targetActor.window.document.readyState != "complete") { + this.webProgress = this.targetActor.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this.webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + } + } else { + // Listen to will-navigate and do not emit a fake one as we only care about upcoming navigation + this.targetActor.on("will-navigate", this.onWillNavigate); + + // Listen to window-ready and then fake one in order to notify about dom-loading for the existing document + this.targetActor.on("window-ready", this.onWindowReady); + } + // The target actor already emitted a window-ready for the top document when instantiating. + // So fake one for the top document right away. + this.onWindowReady({ + window: this.targetActor.window, + isTopLevel: true, + }); + }, + + onWillNavigate({ + window, + isTopLevel, + newURI, + navigationStart, + isFrameSwitching, + }) { + // Ignore iframes + if (!isTopLevel) { + return; + } + + this.emit("will-navigate", { + time: navigationStart - WILL_NAVIGATE_TIME_SHIFT, + newURI, + isFrameSwitching, + }); + }, + + onWindowReady({ window, isTopLevel, isFrameSwitching }) { + // Ignore iframes + if (!isTopLevel) { + return; + } + + const time = window.performance.timing.navigationStart; + + this.emit("dom-loading", { + time, + isFrameSwitching, + }); + + const { readyState } = window.document; + if (readyState != "interactive" && readyState != "complete") { + // When EFT is enabled, we track this event via the WebProgressListener interface. + if (!this.targetActor.ignoreSubFrames) { + window.addEventListener( + "DOMContentLoaded", + e => this.onContentLoaded(e, isFrameSwitching), + { + once: true, + } + ); + } + } else { + this.onContentLoaded({ target: window.document }, isFrameSwitching); + } + if (readyState != "complete") { + // When EFT is enabled, we track the load event via the WebProgressListener interface. + if (!this.targetActor.ignoreSubFrames) { + window.addEventListener("load", e => this.onLoad(e, isFrameSwitching), { + once: true, + }); + } + } else { + this.onLoad({ target: window.document }, isFrameSwitching); + } + }, + + onContentLoaded(event, isFrameSwitching) { + if (this.destroyed) { + return; + } + // milliseconds since the UNIX epoch, when the parser finished its work + // on the main document, that is when its Document.readyState changes to + // 'interactive' and the corresponding readystatechange event is thrown + const window = event.target.defaultView; + const time = window.performance.timing.domInteractive; + this.emit("dom-interactive", { time, isFrameSwitching }); + }, + + onLoad(event, isFrameSwitching) { + if (this.destroyed) { + return; + } + // milliseconds since the UNIX epoch, when the parser finished its work + // on the main document, that is when its Document.readyState changes to + // 'complete' and the corresponding readystatechange event is thrown + const window = event.target.defaultView; + const time = window.performance.timing.domComplete; + this.emit("dom-complete", { + time, + isFrameSwitching, + hasNativeConsoleAPI: this.hasNativeConsoleAPI(window), + }); + }, + + onStateChange(progress, request, flag, status) { + progress.QueryInterface(Ci.nsIDocShell); + // Ignore destroyed, or progress for same-process iframes + if (progress.isBeingDestroyed() || progress != this.webProgress) { + return; + } + + const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + const isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + const window = progress.DOMWindow; + if (isDocument && isStop) { + const time = window.performance.timing.domInteractive; + this.emit("dom-interactive", { time }); + } else if (isWindow && isStop) { + const time = window.performance.timing.domComplete; + this.emit("dom-complete", { + time, + hasNativeConsoleAPI: this.hasNativeConsoleAPI(window), + }); + } + }, + + /** + * Tells if the window.console object is native or overwritten by script in + * the page. + * + * @param nsIDOMWindow window + * The window object you want to check. + * @return boolean + * True if the window.console object is native, or false otherwise. + */ + hasNativeConsoleAPI(window) { + let isNative = false; + try { + // We are very explicitly examining the "console" property of + // the non-Xrayed object here. + const console = window.wrappedJSObject.console; + // In xpcshell tests, console ends up being undefined and XPCNativeWrapper + // crashes in debug builds. + if (console) { + isNative = new XPCNativeWrapper(console).IS_NATIVE_CONSOLE === true; + } + } catch (ex) { + // ignore + } + return isNative; + }, + + destroy() { + // Also use a flag to silent onContentLoad and onLoad events + this.destroyed = true; + this.targetActor.off("will-navigate", this.onWillNavigate); + this.targetActor.off("window-ready", this.onWindowReady); + if (this.webProgress) { + this.webProgress.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), +}; diff --git a/devtools/server/actors/webconsole/listeners/moz.build b/devtools/server/actors/webconsole/listeners/moz.build new file mode 100644 index 0000000000..089de4a087 --- /dev/null +++ b/devtools/server/actors/webconsole/listeners/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "console-api.js", + "console-file-activity.js", + "console-reflow.js", + "console-service.js", + "document-events.js", +) diff --git a/devtools/server/actors/webconsole/moz.build b/devtools/server/actors/webconsole/moz.build new file mode 100644 index 0000000000..58d8a70211 --- /dev/null +++ b/devtools/server/actors/webconsole/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DIRS += [ + "commands", + "listeners", +] + +DevToolsModules( + "eager-ecma-allowlist.js", + "eager-function-allowlist.js", + "eval-with-debugger.js", + "utils.js", + "webidl-pure-allowlist.js", + "webidl-unsafe-getters-names.js", + "worker-listeners.js", +) diff --git a/devtools/server/actors/webconsole/utils.js b/devtools/server/actors/webconsole/utils.js new file mode 100644 index 0000000000..2834696944 --- /dev/null +++ b/devtools/server/actors/webconsole/utils.js @@ -0,0 +1,160 @@ +/* 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 CONSOLE_WORKER_IDS = (exports.CONSOLE_WORKER_IDS = new Set([ + "SharedWorker", + "ServiceWorker", + "Worker", +])); + +var WebConsoleUtils = { + /** + * Given a message, return one of CONSOLE_WORKER_IDS if it matches + * one of those. + * + * @return string + */ + getWorkerType(message) { + const innerID = message?.innerID; + return CONSOLE_WORKER_IDS.has(innerID) ? innerID : null; + }, + + /** + * Gets the ID of the inner window of this DOM window. + * + * @param nsIDOMWindow window + * @return integer|null + * Inner ID for the given window, null if we can't access it. + */ + getInnerWindowId(window) { + // Might throw with SecurityError: Permission denied to access property + // "windowGlobalChild" on cross-origin object. + try { + return window.windowGlobalChild.innerWindowId; + } catch (e) { + return null; + } + }, + + /** + * Recursively gather a list of inner window ids given a + * top level window. + * + * @param nsIDOMWindow window + * @return Array + * list of inner window ids. + */ + getInnerWindowIDsForFrames(window) { + const innerWindowID = this.getInnerWindowId(window); + if (innerWindowID === null) { + return []; + } + + let ids = [innerWindowID]; + + if (window.frames) { + for (let i = 0; i < window.frames.length; i++) { + const frame = window.frames[i]; + ids = ids.concat(this.getInnerWindowIDsForFrames(frame)); + } + } + + return ids; + }, + + /** + * Create a grip for the given value. If the value is an object, + * an object wrapper will be created. + * + * @param mixed value + * The value you want to create a grip for, before sending it to the + * client. + * @param function objectWrapper + * If the value is an object then the objectWrapper function is + * invoked to give us an object grip. See this.getObjectGrip(). + * @return mixed + * The value grip. + */ + createValueGrip(value, objectWrapper) { + switch (typeof value) { + case "boolean": + return value; + case "string": + return objectWrapper(value); + case "number": + if (value === Infinity) { + return { type: "Infinity" }; + } else if (value === -Infinity) { + return { type: "-Infinity" }; + } else if (Number.isNaN(value)) { + return { type: "NaN" }; + } else if (!value && 1 / value === -Infinity) { + return { type: "-0" }; + } + return value; + case "undefined": + return { type: "undefined" }; + case "object": + if (value === null) { + return { type: "null" }; + } + // Fall through. + case "function": + case "record": + case "tuple": + return objectWrapper(value); + default: + console.error( + "Failed to provide a grip for value of " + typeof value + ": " + value + ); + return null; + } + }, + + /** + * Remove any frames in a stack that are above a debugger-triggered evaluation + * and will correspond with devtools server code, which we never want to show + * to the user. + * + * @param array stack + * An array of frames, with the topmost first, and each of which has a + * 'filename' property. + * @return array + * An array of stack frames with any devtools server frames removed. + * The original array is not modified. + */ + removeFramesAboveDebuggerEval(stack) { + const debuggerEvalFilename = "debugger eval code"; + + // Remove any frames for server code above the last debugger eval frame. + const evalIndex = stack.findIndex(({ filename }, idx, arr) => { + const nextFrame = arr[idx + 1]; + return ( + filename == debuggerEvalFilename && + (!nextFrame || nextFrame.filename !== debuggerEvalFilename) + ); + }); + if (evalIndex != -1) { + return stack.slice(0, evalIndex + 1); + } + + // In some cases (e.g. evaluated expression with SyntaxError), we might not have a + // "debugger eval code" frame but still have internal ones. If that's the case, we + // return null as the end user shouldn't see those frames. + if ( + stack.some( + ({ filename }) => + filename && filename.startsWith("resource://devtools/") + ) + ) { + return null; + } + + return stack; + }, +}; + +exports.WebConsoleUtils = WebConsoleUtils; diff --git a/devtools/server/actors/webconsole/webidl-pure-allowlist.js b/devtools/server/actors/webconsole/webidl-pure-allowlist.js new file mode 100644 index 0000000000..3db5a14da1 --- /dev/null +++ b/devtools/server/actors/webconsole/webidl-pure-allowlist.js @@ -0,0 +1,87 @@ +/* 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/. */ + +// This file is automatically generated by the GenerateDataFromWebIdls.py +// script. Do not modify it manually. +"use strict"; + +module.exports = { + DOMTokenList: { + prototype: ["item", "contains"], + }, + Document: { + prototype: [ + "getSelection", + "hasStorageAccess", + "getElementsByTagName", + "getElementsByTagNameNS", + "getElementsByClassName", + "getElementById", + "getElementsByName", + "querySelector", + "querySelectorAll", + "createNSResolver", + ], + }, + Element: { + prototype: [ + "getAttributeNames", + "getAttribute", + "getAttributeNS", + "hasAttribute", + "hasAttributeNS", + "hasAttributes", + "closest", + "matches", + "webkitMatchesSelector", + "getElementsByTagName", + "getElementsByTagNameNS", + "getElementsByClassName", + "mozMatchesSelector", + "querySelector", + "querySelectorAll", + "getAsFlexContainer", + "getGridFragments", + "hasGridFragments", + "getElementsWithGrid", + ], + }, + FormData: { + prototype: ["entries", "keys", "values"], + }, + Headers: { + prototype: ["entries", "keys", "values"], + }, + Node: { + prototype: [ + "getRootNode", + "hasChildNodes", + "isSameNode", + "isEqualNode", + "compareDocumentPosition", + "contains", + "lookupPrefix", + "lookupNamespaceURI", + "isDefaultNamespace", + ], + }, + Performance: { + prototype: ["now"], + }, + Range: { + prototype: [ + "isPointInRange", + "comparePoint", + "intersectsNode", + "getClientRects", + "getBoundingClientRect", + ], + }, + Selection: { + prototype: ["getRangeAt", "containsNode"], + }, + URLSearchParams: { + prototype: ["entries", "keys", "values"], + }, +}; diff --git a/devtools/server/actors/webconsole/webidl-unsafe-getters-names.js b/devtools/server/actors/webconsole/webidl-unsafe-getters-names.js new file mode 100644 index 0000000000..eb571c7545 --- /dev/null +++ b/devtools/server/actors/webconsole/webidl-unsafe-getters-names.js @@ -0,0 +1,20 @@ +/* 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/. */ + +// This file is automatically generated by the GenerateDataFromWebIdls.py +// script. Do not modify it manually. +"use strict"; + +module.exports = [ + "InstallTrigger", + "farthestViewportElement", + "mozPreservesPitch", + "mozPressure", + "nearestViewportElement", + "onmouseenter", + "onmouseleave", + "onmozfullscreenchange", + "onmozfullscreenerror", + "onreadystatechange", +]; diff --git a/devtools/server/actors/webconsole/worker-listeners.js b/devtools/server/actors/webconsole/worker-listeners.js new file mode 100644 index 0000000000..6861c6da62 --- /dev/null +++ b/devtools/server/actors/webconsole/worker-listeners.js @@ -0,0 +1,35 @@ +/* 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/. */ + +/* global setConsoleEventHandler, retrieveConsoleEvents */ + +"use strict"; + +// This file is loaded on the server side for worker debugging. +// Since the server is running in the worker thread, it doesn't +// have access to Services / Components but the listeners defined here +// are imported by webconsole-utils and used for the webconsole actor. +class ConsoleAPIListener { + constructor(window, listener, consoleID) { + this.window = window; + this.listener = listener; + this.consoleID = consoleID; + this.observe = this.observe.bind(this); + } + + init() { + setConsoleEventHandler(this.observe); + } + destroy() { + setConsoleEventHandler(null); + } + observe(message) { + this.listener(message.wrappedJSObject); + } + getCachedMessages() { + return retrieveConsoleEvents(); + } +} + +exports.ConsoleAPIListener = ConsoleAPIListener; |