diff options
Diffstat (limited to 'devtools/shared/security')
-rw-r--r-- | devtools/shared/security/DevToolsSocketStatus.sys.mjs | 60 | ||||
-rw-r--r-- | devtools/shared/security/auth.js | 220 | ||||
-rw-r--r-- | devtools/shared/security/moz.build | 15 | ||||
-rw-r--r-- | devtools/shared/security/prompt.js | 197 | ||||
-rw-r--r-- | devtools/shared/security/socket.js | 685 | ||||
-rw-r--r-- | devtools/shared/security/tests/chrome/chrome.toml | 4 | ||||
-rw-r--r-- | devtools/shared/security/tests/chrome/test_websocket-transport.html | 72 | ||||
-rw-r--r-- | devtools/shared/security/tests/xpcshell/.eslintrc.js | 6 | ||||
-rw-r--r-- | devtools/shared/security/tests/xpcshell/head_dbg.js | 95 | ||||
-rw-r--r-- | devtools/shared/security/tests/xpcshell/test_devtools_socket_status.js | 137 | ||||
-rw-r--r-- | devtools/shared/security/tests/xpcshell/testactors.js | 16 | ||||
-rw-r--r-- | devtools/shared/security/tests/xpcshell/xpcshell.toml | 8 |
12 files changed, 1515 insertions, 0 deletions
diff --git a/devtools/shared/security/DevToolsSocketStatus.sys.mjs b/devtools/shared/security/DevToolsSocketStatus.sys.mjs new file mode 100644 index 0000000000..8acfbdd8c4 --- /dev/null +++ b/devtools/shared/security/DevToolsSocketStatus.sys.mjs @@ -0,0 +1,60 @@ +/* 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/. */ + +/** + * Singleton that should be updated whenever a socket is opened or closed for + * incoming connections. + * + * Notifies observers via "devtools-socket" when the status might have have + * changed. + * + * Currently observed by browser/base/content/browser.js in order to display + * the "remote control" visual cue also used for Marionette and Remote Agent. + */ +export const DevToolsSocketStatus = { + _browserToolboxSockets: 0, + _otherSockets: 0, + + /** + * Check if there are opened DevTools sockets. + * + * @param {Object=} options + * @param {boolean=} options.excludeBrowserToolboxSockets + * Exclude sockets opened by local Browser Toolbox sessions. Defaults to + * false. + * + * @return {boolean} + * Returns true if there are DevTools sockets opened and matching the + * provided options if any. + */ + hasSocketOpened(options = {}) { + const { excludeBrowserToolboxSockets = false } = options; + if (excludeBrowserToolboxSockets) { + return this._otherSockets > 0; + } + return this._browserToolboxSockets + this._otherSockets > 0; + }, + + notifySocketOpened(options) { + const { fromBrowserToolbox } = options; + if (fromBrowserToolbox) { + this._browserToolboxSockets++; + } else { + this._otherSockets++; + } + + Services.obs.notifyObservers(this, "devtools-socket"); + }, + + notifySocketClosed(options) { + const { fromBrowserToolbox } = options; + if (fromBrowserToolbox) { + this._browserToolboxSockets--; + } else { + this._otherSockets--; + } + + Services.obs.notifyObservers(this, "devtools-socket"); + }, +}; diff --git a/devtools/shared/security/auth.js b/devtools/shared/security/auth.js new file mode 100644 index 0000000000..57e3ebe6ff --- /dev/null +++ b/devtools/shared/security/auth.js @@ -0,0 +1,220 @@ +/* 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, + "prompt", + "resource://devtools/shared/security/prompt.js" +); + +/** + * A simple enum-like object with keys mirrored to values. + * This makes comparison to a specfic value simpler without having to repeat and + * mis-type the value. + */ +function createEnum(obj) { + for (const key in obj) { + obj[key] = key; + } + return obj; +} + +/** + * |allowConnection| implementations can return various values as their |result| + * field to indicate what action to take. By specifying these, we can + * centralize the common actions available, while still allowing embedders to + * present their UI in whatever way they choose. + */ +var AuthenticationResult = (exports.AuthenticationResult = createEnum({ + /** + * Close all listening sockets, and disable them from opening again. + */ + DISABLE_ALL: null, + + /** + * Deny the current connection. + */ + DENY: null, + + /** + * Additional data needs to be exchanged before a result can be determined. + */ + PENDING: null, + + /** + * Allow the current connection. + */ + ALLOW: null, + + /** + * Allow the current connection, and persist this choice for future + * connections from the same client. This requires a trustable mechanism to + * identify the client in the future. + */ + ALLOW_PERSIST: null, +})); + +/** + * An |Authenticator| implements an authentication mechanism via various hooks + * in the client and server debugger socket connection path (see socket.js). + * + * |Authenticator|s are stateless objects. Each hook method is passed the state + * it needs by the client / server code in socket.js. + * + * Separate instances of the |Authenticator| are created for each use (client + * connection, server listener) in case some methods are customized by the + * embedder for a given use case. + */ +var Authenticators = {}; + +/** + * The Prompt authenticator displays a server-side user prompt that includes + * connection details, and asks the user to verify the connection. There are + * no cryptographic properties at work here, so it is up to the user to be sure + * that the client can be trusted. + */ +var Prompt = (Authenticators.Prompt = {}); + +Prompt.mode = "PROMPT"; + +Prompt.Client = function () {}; +Prompt.Client.prototype = { + mode: Prompt.mode, + + /** + * When client is about to make a new connection, verify that the connection settings + * are compatible with this authenticator. + * @throws if validation requirements are not met + */ + validateSettings() {}, + + /** + * When client has just made a new socket connection, validate the connection + * to ensure it meets the authenticator's policies. + * + * @param host string + * The host name or IP address of the devtools server. + * @param port number + * The port number of the devtools server. + * @param encryption boolean (optional) + * Whether the server requires encryption. Defaults to false. + * @param s nsISocketTransport + * Underlying socket transport, in case more details are needed. + * @return boolean + * Whether the connection is valid. + */ + validateConnection() { + return true; + }, + + /** + * Work with the server to complete any additional steps required by this + * authenticator's policies. + * + * Debugging commences after this hook completes successfully. + * + * @param host string + * The host name or IP address of the devtools server. + * @param port number + * The port number of the devtools server. + * @param encryption boolean (optional) + * Whether the server requires encryption. Defaults to false. + * @param transport DebuggerTransport + * A transport that can be used to communicate with the server. + * @return A promise can be used if there is async behavior. + */ + authenticate() {}, +}; + +Prompt.Server = function () {}; +Prompt.Server.prototype = { + mode: Prompt.mode, + + /** + * Augment the service discovery advertisement with any additional data needed + * to support this authentication mode. + * + * @param listener SocketListener + * The socket listener that was just opened. + * @param advertisement object + * The advertisement being built. + */ + augmentAdvertisement(listener, advertisement) { + advertisement.authentication = Prompt.mode; + }, + + /** + * Determine whether a connection the server should be allowed or not based on + * this authenticator's policies. + * + * @param session object + * In PROMPT mode, the |session| includes: + * { + * client: { + * host, + * port + * }, + * server: { + * host, + * port + * }, + * transport + * } + * @return An AuthenticationResult value. + * A promise that will be resolved to the above is also allowed. + */ + authenticate({ client, server }) { + if (!Services.prefs.getBoolPref("devtools.debugger.prompt-connection")) { + return AuthenticationResult.ALLOW; + } + return this.allowConnection({ + authentication: this.mode, + client, + server, + }); + }, + + /** + * Prompt the user to accept or decline the incoming connection. The default + * implementation is used unless this is overridden on a particular + * authenticator instance. + * + * It is expected that the implementation of |allowConnection| will show a + * prompt to the user so that they can allow or deny the connection. + * + * @param session object + * In PROMPT mode, the |session| includes: + * { + * authentication: "PROMPT", + * client: { + * host, + * port + * }, + * server: { + * host, + * port + * } + * } + * @return An AuthenticationResult value. + * A promise that will be resolved to the above is also allowed. + */ + allowConnection: prompt.Server.defaultAllowConnection, +}; + +exports.Authenticators = { + get(mode) { + if (!mode) { + mode = Prompt.mode; + } + for (const key in Authenticators) { + const auth = Authenticators[key]; + if (auth.mode === mode) { + return auth; + } + } + throw new Error("Unknown authenticator mode: " + mode); + }, +}; diff --git a/devtools/shared/security/moz.build b/devtools/shared/security/moz.build new file mode 100644 index 0000000000..65a667c8d4 --- /dev/null +++ b/devtools/shared/security/moz.build @@ -0,0 +1,15 @@ +# -*- 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/. + +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"] +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] + +DevToolsModules( + "auth.js", + "DevToolsSocketStatus.sys.mjs", + "prompt.js", + "socket.js", +) diff --git a/devtools/shared/security/prompt.js b/devtools/shared/security/prompt.js new file mode 100644 index 0000000000..b03204cc34 --- /dev/null +++ b/devtools/shared/security/prompt.js @@ -0,0 +1,197 @@ +/* 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"; + +var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +loader.lazyRequireGetter( + this, + "AuthenticationResult", + "resource://devtools/shared/security/auth.js", + true +); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/shared/locales/debugger.properties" +); + +var Client = (exports.Client = {}); +var Server = (exports.Server = {}); + +/** + * During OOB_CERT authentication, a notification dialog like this is used to + * to display a token which the user must transfer through some mechanism to the + * server to authenticate the devices. + * + * This implementation presents the token as text for the user to transfer + * manually. For a mobile device, you should override this implementation with + * something more convenient, such as displaying a QR code. + * + * @param host string + * The host name or IP address of the devtools server. + * @param port number + * The port number of the devtools server. + * @param authResult AuthenticationResult + * Authentication result sent from the server. + * @param oob object (optional) + * The token data to be transferred during OOB_CERT step 8: + * * sha256: hash(ClientCert) + * * k : K(random 128-bit number) + * @return object containing: + * * close: Function to hide the notification + */ +Client.defaultSendOOB = ({ authResult, oob }) => { + // Only show in the PENDING state + if (authResult != AuthenticationResult.PENDING) { + throw new Error("Expected PENDING result, got " + authResult); + } + const title = L10N.getStr("clientSendOOBTitle"); + const header = L10N.getStr("clientSendOOBHeader"); + const hashMsg = L10N.getFormatStr("clientSendOOBHash", oob.sha256); + const token = oob.sha256.replace(/:/g, "").toLowerCase() + oob.k; + const tokenMsg = L10N.getFormatStr("clientSendOOBToken", token); + const msg = `${header}\n\n${hashMsg}\n${tokenMsg}`; + const prompt = Services.prompt; + const flags = prompt.BUTTON_POS_0 * prompt.BUTTON_TITLE_CANCEL; + + // Listen for the window our prompt opens, so we can close it programatically + let promptWindow; + const windowListener = { + onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + win.addEventListener( + "load", + function () { + if ( + win.document.documentElement.getAttribute("id") != "commonDialog" + ) { + return; + } + // Found the window + promptWindow = win; + Services.wm.removeListener(windowListener); + }, + { once: true } + ); + }, + onCloseWindow() {}, + }; + Services.wm.addListener(windowListener); + + // nsIPrompt is typically a blocking API, so |executeSoon| to get around this + DevToolsUtils.executeSoon(() => { + prompt.confirmEx(null, title, msg, flags, null, null, null, null, { + value: false, + }); + }); + + return { + close() { + if (!promptWindow) { + return; + } + promptWindow.document.documentElement.acceptDialog(); + promptWindow = null; + }, + }; +}; + +/** + * Prompt the user to accept or decline the incoming connection. This is the + * default implementation that products embedding the devtools server may + * choose to override. This can be overridden via |allowConnection| on the + * socket's authenticator instance. + * + * @param session object + * The session object will contain at least the following fields: + * { + * authentication, + * client: { + * host, + * port + * }, + * server: { + * host, + * port + * } + * } + * Specific authentication modes may include additional fields. Check + * the different |allowConnection| methods in ./auth.js. + * @return An AuthenticationResult value. + * A promise that will be resolved to the above is also allowed. + */ +Server.defaultAllowConnection = ({ client, server }) => { + const title = L10N.getStr("remoteIncomingPromptTitle"); + const header = L10N.getStr("remoteIncomingPromptHeader"); + const clientEndpoint = `${client.host}:${client.port}`; + const clientMsg = L10N.getFormatStr( + "remoteIncomingPromptClientEndpoint", + clientEndpoint + ); + const serverEndpoint = `${server.host}:${server.port}`; + const serverMsg = L10N.getFormatStr( + "remoteIncomingPromptServerEndpoint", + serverEndpoint + ); + const footer = L10N.getStr("remoteIncomingPromptFooter"); + const msg = `${header}\n\n${clientMsg}\n${serverMsg}\n\n${footer}`; + const disableButton = L10N.getStr("remoteIncomingPromptDisable"); + const prompt = Services.prompt; + const flags = + prompt.BUTTON_POS_0 * prompt.BUTTON_TITLE_OK + + prompt.BUTTON_POS_1 * prompt.BUTTON_TITLE_CANCEL + + prompt.BUTTON_POS_2 * prompt.BUTTON_TITLE_IS_STRING + + prompt.BUTTON_POS_1_DEFAULT; + const result = prompt.confirmEx( + null, + title, + msg, + flags, + null, + null, + disableButton, + null, + { value: false } + ); + if (result === 0) { + return AuthenticationResult.ALLOW; + } + if (result === 2) { + return AuthenticationResult.DISABLE_ALL; + } + return AuthenticationResult.DENY; +}; + +/** + * During OOB_CERT authentication, the user must transfer some data through some + * out of band mechanism from the client to the server to authenticate the + * devices. + * + * This implementation prompts the user for a token as constructed by + * |Client.defaultSendOOB| that the user needs to transfer manually. For a + * mobile device, you should override this implementation with something more + * convenient, such as reading a QR code. + * + * @return An object containing: + * * sha256: hash(ClientCert) + * * k : K(random 128-bit number) + * A promise that will be resolved to the above is also allowed. + */ +Server.defaultReceiveOOB = () => { + const title = L10N.getStr("serverReceiveOOBTitle"); + const msg = L10N.getStr("serverReceiveOOBBody"); + let input = { value: null }; + const prompt = Services.prompt; + const result = prompt.prompt(null, title, msg, input, null, { value: false }); + if (!result) { + return null; + } + // Re-create original object from token + input = input.value.trim(); + let sha256 = input.substring(0, 64); + sha256 = sha256.replace(/\w{2}/g, "$&:").slice(0, -1).toUpperCase(); + const k = input.substring(64); + return { sha256, k }; +}; diff --git a/devtools/shared/security/socket.js b/devtools/shared/security/socket.js new file mode 100644 index 0000000000..961ca13310 --- /dev/null +++ b/devtools/shared/security/socket.js @@ -0,0 +1,685 @@ +/* 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"; + +// Ensure PSM is initialized to support TLS sockets +Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports); + +var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +var { dumpn } = DevToolsUtils; +loader.lazyRequireGetter( + this, + "WebSocketServer", + "resource://devtools/server/socket/websocket-server.js" +); +loader.lazyRequireGetter( + this, + "DebuggerTransport", + "resource://devtools/shared/transport/transport.js", + true +); +loader.lazyRequireGetter( + this, + "WebSocketDebuggerTransport", + "resource://devtools/shared/transport/websocket-transport.js" +); +loader.lazyRequireGetter( + this, + "discovery", + "resource://devtools/shared/discovery/discovery.js" +); +loader.lazyRequireGetter( + this, + "Authenticators", + "resource://devtools/shared/security/auth.js", + true +); +loader.lazyRequireGetter( + this, + "AuthenticationResult", + "resource://devtools/shared/security/auth.js", + true +); +const lazy = {}; + +DevToolsUtils.defineLazyGetter( + lazy, + "DevToolsSocketStatus", + () => + ChromeUtils.importESModule( + "resource://devtools/shared/security/DevToolsSocketStatus.sys.mjs", + { + // DevToolsSocketStatus is also accessed by non-devtools modules and + // should be loaded in the regular / shared global. + loadInDevToolsLoader: false, + } + ).DevToolsSocketStatus +); + +loader.lazyRequireGetter( + this, + "EventEmitter", + "resource://devtools/shared/event-emitter.js" +); + +DevToolsUtils.defineLazyGetter(this, "nsFile", () => { + return Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" + ); +}); + +DevToolsUtils.defineLazyGetter(this, "socketTransportService", () => { + return Cc["@mozilla.org/network/socket-transport-service;1"].getService( + Ci.nsISocketTransportService + ); +}); + +var DebuggerSocket = {}; + +/** + * Connects to a devtools server socket. + * + * @param host string + * The host name or IP address of the devtools server. + * @param port number + * The port number of the devtools server. + * @param webSocket boolean (optional) + * Whether to use WebSocket protocol to connect. Defaults to false. + * @param authenticator Authenticator (optional) + * |Authenticator| instance matching the mode in use by the server. + * Defaults to a PROMPT instance if not supplied. + * @return promise + * Resolved to a DebuggerTransport instance. + */ +DebuggerSocket.connect = async function (settings) { + // Default to PROMPT |Authenticator| instance if not supplied + if (!settings.authenticator) { + settings.authenticator = new (Authenticators.get().Client)(); + } + _validateSettings(settings); + // eslint-disable-next-line no-shadow + const { host, port, authenticator } = settings; + const transport = await _getTransport(settings); + await authenticator.authenticate({ + host, + port, + transport, + }); + transport.connectionSettings = settings; + return transport; +}; + +/** + * Validate that the connection settings have been set to a supported configuration. + */ +function _validateSettings(settings) { + const { authenticator } = settings; + + authenticator.validateSettings(settings); +} + +/** + * Try very hard to create a DevTools transport, potentially making several + * connect attempts in the process. + * + * @param host string + * The host name or IP address of the devtools server. + * @param port number + * The port number of the devtools server. + * @param webSocket boolean (optional) + * Whether to use WebSocket protocol to connect to the server. Defaults to false. + * @param authenticator Authenticator + * |Authenticator| instance matching the mode in use by the server. + * Defaults to a PROMPT instance if not supplied. + * @return transport DebuggerTransport + * A possible DevTools transport (if connection succeeded and streams + * are actually alive and working) + */ +var _getTransport = async function (settings) { + const { host, port, webSocket } = settings; + + if (webSocket) { + // Establish a connection and wait until the WebSocket is ready to send and receive + const socket = await new Promise((resolve, reject) => { + const s = new WebSocket(`ws://${host}:${port}`); + s.onopen = () => resolve(s); + s.onerror = err => reject(err); + }); + + return new WebSocketDebuggerTransport(socket); + } + + const attempt = await _attemptTransport(settings); + if (attempt.transport) { + // Success + return attempt.transport; + } + + throw new Error("Connection failed"); +}; + +/** + * Make a single attempt to connect and create a DevTools transport. + * + * @param host string + * The host name or IP address of the devtools server. + * @param port number + * The port number of the devtools server. + * @param authenticator Authenticator + * |Authenticator| instance matching the mode in use by the server. + * Defaults to a PROMPT instance if not supplied. + * @return transport DebuggerTransport + * A possible DevTools transport (if connection succeeded and streams + * are actually alive and working) + * @return s nsISocketTransport + * Underlying socket transport, in case more details are needed. + */ +var _attemptTransport = async function (settings) { + const { authenticator } = settings; + // _attemptConnect only opens the streams. Any failures at that stage + // aborts the connection process immedidately. + const { s, input, output } = await _attemptConnect(settings); + + // Check if the input stream is alive. + let alive; + try { + const results = await _isInputAlive(input); + alive = results.alive; + } catch (e) { + // For other unexpected errors, like NS_ERROR_CONNECTION_REFUSED, we reach + // this block. + input.close(); + output.close(); + throw e; + } + + // The |Authenticator| examines the connection as well and may determine it + // should be dropped. + alive = + alive && + authenticator.validateConnection({ + host: settings.host, + port: settings.port, + socket: s, + }); + + let transport; + if (alive) { + transport = new DebuggerTransport(input, output); + } else { + // Something went wrong, close the streams. + input.close(); + output.close(); + } + + return { transport, s }; +}; + +/** + * Try to connect to a remote server socket. + * + * If successsful, the socket transport and its opened streams are returned. + * Typically, this will only fail if the host / port is unreachable. Other + * problems, such as security errors, will allow this stage to succeed, but then + * fail later when the streams are actually used. + * @return s nsISocketTransport + * Underlying socket transport, in case more details are needed. + * @return input nsIAsyncInputStream + * The socket's input stream. + * @return output nsIAsyncOutputStream + * The socket's output stream. + */ +var _attemptConnect = async function ({ host, port }) { + const s = socketTransportService.createTransport([], host, port, null, null); + + // Force disabling IPV6 if we aren't explicitely connecting to an IPv6 address + // It fails intermitently on MacOS when opening the Browser Toolbox (bug 1615412) + if (!host.includes(":")) { + s.connectionFlags |= Ci.nsISocketTransport.DISABLE_IPV6; + } + + // By default the CONNECT socket timeout is very long, 65535 seconds, + // so that if we race to be in CONNECT state while the server socket is still + // initializing, the connection is stuck in connecting state for 18.20 hours! + s.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, 2); + + let input; + let output; + return new Promise((resolve, reject) => { + s.setEventSink( + { + onTransportStatus(transport, status) { + if (status != Ci.nsISocketTransport.STATUS_CONNECTING_TO) { + return; + } + try { + input = s.openInputStream(0, 0, 0); + } catch (e) { + reject(e); + } + resolve({ s, input, output }); + }, + }, + Services.tm.currentThread + ); + + // openOutputStream may throw NS_ERROR_NOT_INITIALIZED if we hit some race + // where the nsISocketTransport gets shutdown in between its instantiation and + // the call to this method. + try { + output = s.openOutputStream(0, 0, 0); + } catch (e) { + reject(e); + } + }).catch(e => { + if (input) { + input.close(); + } + if (output) { + output.close(); + } + DevToolsUtils.reportException("_attemptConnect", e); + }); +}; + +/** + * Check if the input stream is alive. + */ +function _isInputAlive(input) { + return new Promise((resolve, reject) => { + input.asyncWait( + { + onInputStreamReady(stream) { + try { + stream.available(); + resolve({ alive: true }); + } catch (e) { + reject(e); + } + }, + }, + 0, + 0, + Services.tm.currentThread + ); + }); +} + +/** + * Creates a new socket listener for remote connections to the DevToolsServer. + * This helps contain and organize the parts of the server that may differ or + * are particular to one given listener mechanism vs. another. + * This can be closed at any later time by calling |close|. + * If remote connections are disabled, an error is thrown. + * + * @param {DevToolsServer} devToolsServer + * @param {Object} socketOptions + * options of socket as follows + * { + * authenticator: + * Controls the |Authenticator| used, which hooks various socket steps to + * implement an authentication policy. It is expected that different use + * cases may override pieces of the |Authenticator|. See auth.js. + * We set the default |Authenticator|, which is |Prompt|. + * discoverable: + * Controls whether this listener is announced via the service discovery + * mechanism. Defaults is false. + * fromBrowserToolbox: + * Should only be passed when opening a socket for a Browser Toolbox + * session. DevToolsSocketStatus will track the socket separately to + * avoid triggering the visual cue in the URL bar. + * portOrPath: + * The port or path to listen on. + * If given an integer, the port to listen on. Use -1 to choose any available + * port. Otherwise, the path to the unix socket domain file to listen on. + * Defaults is null. + * webSocket: + * Whether to use WebSocket protocol. Defaults is false. + * } + */ +function SocketListener(devToolsServer, socketOptions) { + this._devToolsServer = devToolsServer; + + // Set socket options with default value + this._socketOptions = { + authenticator: + socketOptions.authenticator || new (Authenticators.get().Server)(), + discoverable: !!socketOptions.discoverable, + fromBrowserToolbox: !!socketOptions.fromBrowserToolbox, + portOrPath: socketOptions.portOrPath || null, + webSocket: !!socketOptions.webSocket, + }; + + EventEmitter.decorate(this); +} + +SocketListener.prototype = { + get authenticator() { + return this._socketOptions.authenticator; + }, + + get discoverable() { + return this._socketOptions.discoverable; + }, + + get fromBrowserToolbox() { + return this._socketOptions.fromBrowserToolbox; + }, + + get portOrPath() { + return this._socketOptions.portOrPath; + }, + + get webSocket() { + return this._socketOptions.webSocket; + }, + + /** + * Validate that all options have been set to a supported configuration. + */ + _validateOptions() { + if (this.portOrPath === null) { + throw new Error("Must set a port / path to listen on."); + } + if (this.discoverable && !Number(this.portOrPath)) { + throw new Error("Discovery only supported for TCP sockets."); + } + }, + + /** + * Listens on the given port or socket file for remote debugger connections. + */ + open() { + this._validateOptions(); + this._devToolsServer.addSocketListener(this); + + let flags = Ci.nsIServerSocket.KeepWhenOffline; + // A preference setting can force binding on the loopback interface. + if (Services.prefs.getBoolPref("devtools.debugger.force-local")) { + flags |= Ci.nsIServerSocket.LoopbackOnly; + } + + const self = this; + return (async function () { + const backlog = 4; + self._socket = self._createSocketInstance(); + if (self.isPortBased) { + const port = Number(self.portOrPath); + self._socket.initSpecialConnection(port, flags, backlog); + } else if (self.portOrPath.startsWith("/")) { + const file = nsFile(self.portOrPath); + if (file.exists()) { + file.remove(false); + } + self._socket.initWithFilename(file, parseInt("666", 8), backlog); + } else { + // Path isn't absolute path, so we use abstract socket address + self._socket.initWithAbstractAddress(self.portOrPath, backlog); + } + self._socket.asyncListen(self); + dumpn("Socket listening on: " + (self.port || self.portOrPath)); + })() + .then(() => { + lazy.DevToolsSocketStatus.notifySocketOpened({ + fromBrowserToolbox: self.fromBrowserToolbox, + }); + this._advertise(); + }) + .catch(e => { + dumpn( + "Could not start debugging listener on '" + + this.portOrPath + + "': " + + e + ); + this.close(); + }); + }, + + _advertise() { + if (!this.discoverable || !this.port) { + return; + } + + const advertisement = { + port: this.port, + }; + + this.authenticator.augmentAdvertisement(this, advertisement); + + discovery.addService("devtools", advertisement); + }, + + _createSocketInstance() { + return Cc["@mozilla.org/network/server-socket;1"].createInstance( + Ci.nsIServerSocket + ); + }, + + /** + * Closes the SocketListener. Notifies the server to remove the listener from + * the set of active SocketListeners. + */ + close() { + if (this.discoverable && this.port) { + discovery.removeService("devtools"); + } + if (this._socket) { + this._socket.close(); + this._socket = null; + + lazy.DevToolsSocketStatus.notifySocketClosed({ + fromBrowserToolbox: this.fromBrowserToolbox, + }); + } + this._devToolsServer.removeSocketListener(this); + }, + + get host() { + if (!this._socket) { + return null; + } + if (Services.prefs.getBoolPref("devtools.debugger.force-local")) { + return "127.0.0.1"; + } + return "0.0.0.0"; + }, + + /** + * Gets whether this listener uses a port number vs. a path. + */ + get isPortBased() { + return !!Number(this.portOrPath); + }, + + /** + * Gets the port that a TCP socket listener is listening on, or null if this + * is not a TCP socket (so there is no port). + */ + get port() { + if (!this.isPortBased || !this._socket) { + return null; + } + return this._socket.port; + }, + + onAllowedConnection(transport) { + dumpn("onAllowedConnection, transport: " + transport); + this.emit("accepted", transport, this); + }, + + // nsIServerSocketListener implementation + + onSocketAccepted: DevToolsUtils.makeInfallible(function ( + socket, + socketTransport + ) { + const connection = new ServerSocketConnection(this, socketTransport); + connection.once("allowed", this.onAllowedConnection.bind(this)); + }, + "SocketListener.onSocketAccepted"), + + onStopListening(socket, status) { + dumpn("onStopListening, status: " + status); + }, +}; + +/** + * A |ServerSocketConnection| is created by a |SocketListener| for each accepted + * incoming socket. + */ +function ServerSocketConnection(listener, socketTransport) { + this._listener = listener; + this._socketTransport = socketTransport; + this._handle(); + EventEmitter.decorate(this); +} + +ServerSocketConnection.prototype = { + get authentication() { + return this._listener.authenticator.mode; + }, + + get host() { + return this._socketTransport.host; + }, + + get port() { + return this._socketTransport.port; + }, + + get address() { + return this.host + ":" + this.port; + }, + + get client() { + const client = { + host: this.host, + port: this.port, + }; + return client; + }, + + get server() { + const server = { + host: this._listener.host, + port: this._listener.port, + }; + return server; + }, + + /** + * This is the main authentication workflow. If any pieces reject a promise, + * the connection is denied. If the entire process resolves successfully, + * the connection is finally handed off to the |DevToolsServer|. + */ + async _handle() { + dumpn("Debugging connection starting authentication on " + this.address); + try { + await this._createTransport(); + await this._authenticate(); + this.allow(); + } catch (e) { + this.deny(e); + } + }, + + /** + * We need to open the streams early on, as that is required in the case of + * TLS sockets to keep the handshake moving. + */ + async _createTransport() { + const input = this._socketTransport.openInputStream(0, 0, 0); + const output = this._socketTransport.openOutputStream(0, 0, 0); + + if (this._listener.webSocket) { + const socket = await WebSocketServer.accept( + this._socketTransport, + input, + output + ); + this._transport = new WebSocketDebuggerTransport(socket); + } else { + this._transport = new DebuggerTransport(input, output); + } + + // Start up the transport to observe the streams in case they are closed + // early. This allows us to clean up our state as well. + this._transport.hooks = { + onTransportClosed: reason => { + this.deny(reason); + }, + }; + this._transport.ready(); + }, + + async _authenticate() { + const result = await this._listener.authenticator.authenticate({ + client: this.client, + server: this.server, + transport: this._transport, + }); + + // If result is fine, we can stop here + if ( + result === AuthenticationResult.ALLOW || + result === AuthenticationResult.ALLOW_PERSIST + ) { + return; + } + + if (result === AuthenticationResult.DISABLE_ALL) { + this._listener._devToolsServer.closeAllSocketListeners(); + Services.prefs.setBoolPref("devtools.debugger.remote-enabled", false); + } + + // If we got an error (DISABLE_ALL, DENY, …), let's throw a NS_ERROR_CONNECTION_REFUSED + // exception + throw Components.Exception("", Cr.NS_ERROR_CONNECTION_REFUSED); + }, + + deny(result) { + if (this._destroyed) { + return; + } + let errorName = result; + for (const name in Cr) { + if (Cr[name] === result) { + errorName = name; + break; + } + } + dumpn( + "Debugging connection denied on " + this.address + " (" + errorName + ")" + ); + if (this._transport) { + this._transport.hooks = null; + this._transport.close(result); + } + this._socketTransport.close(result); + this.destroy(); + }, + + allow() { + if (this._destroyed) { + return; + } + dumpn("Debugging connection allowed on " + this.address); + this.emit("allowed", this._transport); + this.destroy(); + }, + + destroy() { + this._destroyed = true; + this._listener = null; + this._socketTransport = null; + this._transport = null; + }, +}; + +exports.DebuggerSocket = DebuggerSocket; +exports.SocketListener = SocketListener; diff --git a/devtools/shared/security/tests/chrome/chrome.toml b/devtools/shared/security/tests/chrome/chrome.toml new file mode 100644 index 0000000000..036961ca81 --- /dev/null +++ b/devtools/shared/security/tests/chrome/chrome.toml @@ -0,0 +1,4 @@ +[DEFAULT] +tags = "devtools" + +["test_websocket-transport.html"] diff --git a/devtools/shared/security/tests/chrome/test_websocket-transport.html b/devtools/shared/security/tests/chrome/test_websocket-transport.html new file mode 100644 index 0000000000..8e4652fb1d --- /dev/null +++ b/devtools/shared/security/tests/chrome/test_websocket-transport.html @@ -0,0 +1,72 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test the WebSocket debugger transport</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + +window.onload = function() { + const {require} = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs"); + // eslint-disable-next-line mozilla/reject-some-requires + const {DevToolsClient} = require("devtools/client/devtools-client"); + const {DevToolsServer} = require("devtools/server/devtools-server"); + const { SocketListener } = require("devtools/shared/security/socket"); + + Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true); + Services.prefs.setBoolPref("devtools.debugger.prompt-connection", false); + + SimpleTest.registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.debugger.remote-enabled"); + Services.prefs.clearUserPref("devtools.debugger.prompt-connection"); + }); + + add_task(async function() { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + + is(DevToolsServer.listeningSockets, 0, "0 listening sockets"); + + const socketOptions = { + portOrPath: -1, + webSocket: true, + }; + const listener = new SocketListener(DevToolsServer, socketOptions); + ok(listener, "Socket listener created"); + await listener.open(); + is(DevToolsServer.listeningSockets, 1, "1 listening socket"); + + const transport = await DevToolsClient.socketConnect({ + host: "127.0.0.1", + port: listener.port, + webSocket: true, + }); + ok(transport, "Client transport created"); + + const client = new DevToolsClient(transport); + const onUnexpectedClose = () => { + ok(false, "Closed unexpectedly"); + }; + client.on("closed", onUnexpectedClose); + + await client.connect(); + + // Send a message the server + const reply = await client.mainRoot.getRoot(); + is(reply.from, "root", "Got expected response"); + + client.off("closed", onUnexpectedClose); + transport.close(); + listener.close(); + is(DevToolsServer.listeningSockets, 0, "0 listening sockets"); + + DevToolsServer.destroy(); + }); +}; +</script> +</body> +</html> diff --git a/devtools/shared/security/tests/xpcshell/.eslintrc.js b/devtools/shared/security/tests/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..8611c174f5 --- /dev/null +++ b/devtools/shared/security/tests/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools xpcshell eslintrc config. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/shared/security/tests/xpcshell/head_dbg.js b/devtools/shared/security/tests/xpcshell/head_dbg.js new file mode 100644 index 0000000000..30359e6ac3 --- /dev/null +++ b/devtools/shared/security/tests/xpcshell/head_dbg.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* exported DevToolsClient, initTestDevToolsServer */ + +const { loader, require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const xpcInspector = require("xpcInspector"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +// We need to require lazily since will be crashed if we load SocketListener too early +// in xpc shell test due to SocketListener loads PSM module. +loader.lazyRequireGetter( + this, + "SocketListener", + "resource://devtools/shared/security/socket.js", + true +); + +// We do not want to log packets by default, because in some tests, +// we can be sending large amounts of data. The test harness has +// trouble dealing with logging all the data, and we end up with +// intermittent time outs (e.g. bug 775924). +// Services.prefs.setBoolPref("devtools.debugger.log", true); +// Services.prefs.setBoolPref("devtools.debugger.log.verbose", true); +// Enable remote debugging for the relevant tests. +Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true); + +// Convert an nsIScriptError 'logLevel' value into an appropriate string. +function scriptErrorLogLevel(message) { + switch (message.logLevel) { + case Ci.nsIConsoleMessage.info: + return "info"; + case Ci.nsIConsoleMessage.warn: + return "warning"; + default: + Assert.equal(message.logLevel, Ci.nsIConsoleMessage.error); + return "error"; + } +} + +// Register a console listener, so console messages don't just disappear +// into the ether. +var listener = { + observe(message) { + let string; + try { + message.QueryInterface(Ci.nsIScriptError); + dump( + message.sourceName + + ":" + + message.lineNumber + + ": " + + scriptErrorLogLevel(message) + + ": " + + message.errorMessage + + "\n" + ); + string = message.errorMessage; + } catch (ex) { + // Be a little paranoid with message, as the whole goal here is to lose + // no information. + try { + string = "" + message.message; + } catch (e) { + string = "<error converting error message to string>"; + } + } + + // Make sure we exit all nested event loops so that the test can finish. + while (xpcInspector.eventLoopNestLevel > 0) { + xpcInspector.exitNestedEventLoop(); + } + + info("head_dbg.js got console message: " + string + "\n"); + }, +}; + +Services.console.registerListener(listener); + +/** + * Initialize the testing devtools server. + */ +function initTestDevToolsServer() { + const { createRootActor } = require("xpcshell-test/testactors"); + DevToolsServer.setRootActor(createRootActor); + DevToolsServer.init(); +} diff --git a/devtools/shared/security/tests/xpcshell/test_devtools_socket_status.js b/devtools/shared/security/tests/xpcshell/test_devtools_socket_status.js new file mode 100644 index 0000000000..2e41583b05 --- /dev/null +++ b/devtools/shared/security/tests/xpcshell/test_devtools_socket_status.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + useDistinctSystemPrincipalLoader, + releaseDistinctSystemPrincipalLoader, +} = ChromeUtils.importESModule( + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" +); + +const { DevToolsSocketStatus } = ChromeUtils.importESModule( + "resource://devtools/shared/security/DevToolsSocketStatus.sys.mjs" +); + +add_task(async function () { + Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true); + Services.prefs.setBoolPref("devtools.debugger.prompt-connection", false); + + info("Without any server started, all states should be set to false"); + checkSocketStatus(false, false); + + info("Start a first server, expect all states to change to true"); + const server = await setupDevToolsServer({ fromBrowserToolbox: false }); + checkSocketStatus(true, true); + + info("Start another server, expect all states to remain true"); + const otherServer = await setupDevToolsServer({ fromBrowserToolbox: false }); + checkSocketStatus(true, true); + + info("Shutdown one of the servers, expect all states to remain true"); + teardownDevToolsServer(otherServer); + checkSocketStatus(true, true); + + info("Shutdown the other server, expect all states to change to false"); + teardownDevToolsServer(server); + checkSocketStatus(false, false); + + info( + "Start a 'browser toolbox' server, expect only the 'include' state to become true" + ); + const browserToolboxServer = await setupDevToolsServer({ + fromBrowserToolbox: true, + }); + checkSocketStatus(true, false); + + info( + "Shutdown the 'browser toolbox' server, expect all states to become false" + ); + teardownDevToolsServer(browserToolboxServer); + checkSocketStatus(false, false); + + Services.prefs.clearUserPref("devtools.debugger.remote-enabled"); + Services.prefs.clearUserPref("devtools.debugger.prompt-connection"); +}); + +function checkSocketStatus(expectedExcludeFalse, expectedExcludeTrue) { + const openedDefault = DevToolsSocketStatus.hasSocketOpened(); + const openedExcludeFalse = DevToolsSocketStatus.hasSocketOpened({ + excludeBrowserToolboxSockets: false, + }); + const openedExcludeTrue = DevToolsSocketStatus.hasSocketOpened({ + excludeBrowserToolboxSockets: true, + }); + + equal( + openedDefault, + openedExcludeFalse, + "DevToolsSocketStatus.hasSocketOpened should default to excludeBrowserToolboxSockets=false" + ); + equal( + openedExcludeFalse, + expectedExcludeFalse, + "DevToolsSocketStatus matches the expectation for excludeBrowserToolboxSockets=false" + ); + equal( + openedExcludeTrue, + expectedExcludeTrue, + "DevToolsSocketStatus matches the expectation for excludeBrowserToolboxSockets=true" + ); +} + +async function setupDevToolsServer({ fromBrowserToolbox }) { + info("Use the dedicated system principal loader for the DevToolsServer."); + const requester = {}; + const loader = useDistinctSystemPrincipalLoader(requester); + + const { DevToolsServer } = loader.require( + "resource://devtools/server/devtools-server.js" + ); + + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + DevToolsServer.allowChromeProcess = true; + const socketOptions = { + fromBrowserToolbox, + // Pass -1 to automatically choose an available port + portOrPath: -1, + }; + + const listener = new SocketListener(DevToolsServer, socketOptions); + ok(listener, "Socket listener created"); + + // Note that useDistinctSystemPrincipalLoader will lead to reuse the same + // loader if we are creating several servers in a row. The DevToolsServer + // singleton might already have sockets opened. + const listeningSockets = DevToolsServer.listeningSockets; + await listener.open(); + equal( + DevToolsServer.listeningSockets, + listeningSockets + 1, + "A new listening socket was created" + ); + + return { DevToolsServer, listener, requester }; +} + +function teardownDevToolsServer({ DevToolsServer, listener, requester }) { + info("Close the listener socket"); + const listeningSockets = DevToolsServer.listeningSockets; + listener.close(); + equal( + DevToolsServer.listeningSockets, + listeningSockets - 1, + "A listening socket was closed" + ); + + if (DevToolsServer.listeningSockets == 0) { + info("Destroy the temporary devtools server"); + DevToolsServer.destroy(); + } + + if (requester) { + releaseDistinctSystemPrincipalLoader(requester); + } +} diff --git a/devtools/shared/security/tests/xpcshell/testactors.js b/devtools/shared/security/tests/xpcshell/testactors.js new file mode 100644 index 0000000000..0a35f05287 --- /dev/null +++ b/devtools/shared/security/tests/xpcshell/testactors.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const { RootActor } = require("resource://devtools/server/actors/root.js"); +const { + ActorRegistry, +} = require("resource://devtools/server/actors/utils/actor-registry.js"); + +exports.createRootActor = function createRootActor(connection) { + const root = new RootActor(connection, { + globalActorFactories: ActorRegistry.globalActorFactories, + }); + root.applicationType = "xpcshell-tests"; + return root; +}; diff --git a/devtools/shared/security/tests/xpcshell/xpcshell.toml b/devtools/shared/security/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..f0d3c8f1d2 --- /dev/null +++ b/devtools/shared/security/tests/xpcshell/xpcshell.toml @@ -0,0 +1,8 @@ +[DEFAULT] +tags = "devtools" +head = "head_dbg.js" +skip-if = ["os == 'android'"] +firefox-appdir = "browser" +support-files = ["testactors.js"] + +["test_devtools_socket_status.js"] |