gecko/toolkit/devtools/security/socket.js

471 lines
16 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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";
let { Ci, Cc, CC, Cr } = require("chrome");
// Ensure PSM is initialized to support TLS sockets
Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports);
let Services = require("Services");
let promise = require("promise");
let DevToolsUtils = require("devtools/toolkit/DevToolsUtils");
let { dumpn, dumpv } = DevToolsUtils;
loader.lazyRequireGetter(this, "DebuggerTransport",
"devtools/toolkit/transport/transport", true);
loader.lazyRequireGetter(this, "DebuggerServer",
"devtools/server/main", true);
loader.lazyRequireGetter(this, "discovery",
"devtools/toolkit/discovery/discovery");
loader.lazyRequireGetter(this, "cert",
"devtools/toolkit/security/cert");
loader.lazyRequireGetter(this, "setTimeout", "Timer", true);
loader.lazyRequireGetter(this, "clearTimeout", "Timer", true);
DevToolsUtils.defineLazyGetter(this, "nsFile", () => {
return CC("@mozilla.org/file/local;1", "nsIFile", "initWithPath");
});
DevToolsUtils.defineLazyGetter(this, "socketTransportService", () => {
return Cc["@mozilla.org/network/socket-transport-service;1"]
.getService(Ci.nsISocketTransportService);
});
DevToolsUtils.defineLazyGetter(this, "certOverrideService", () => {
return Cc["@mozilla.org/security/certoverride;1"]
.getService(Ci.nsICertOverrideService);
});
DevToolsUtils.defineLazyGetter(this, "nssErrorsService", () => {
return Cc["@mozilla.org/nss_errors_service;1"]
.getService(Ci.nsINSSErrorsService);
});
DevToolsUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
const DBG_STRINGS_URI = "chrome://global/locale/devtools/debugger.properties";
let DebuggerSocket = {};
/**
* Connects to a debugger server socket.
*
* @param host string
* The host name or IP address of the debugger server.
* @param port number
* The port number of the debugger server.
* @param encryption boolean (optional)
* Whether the server requires encryption. Defaults to false.
* @return promise
* Resolved to a DebuggerTransport instance.
*/
DebuggerSocket.connect = Task.async(function*({ host, port, encryption }) {
let attempt = yield _attemptTransport({ host, port, encryption });
if (attempt.transport) {
return attempt.transport; // Success
}
// If the server cert failed validation, store a temporary override and make
// a second attempt.
if (encryption && attempt.certError) {
_storeCertOverride(attempt.s, host, port);
} else {
throw new Error("Connection failed");
}
attempt = yield _attemptTransport({ host, port, encryption });
if (attempt.transport) {
return attempt.transport; // Success
}
throw new Error("Connection failed even after cert override");
});
/**
* Try to connect and create a DevTools transport.
*
* @return transport DebuggerTransport
* A possible DevTools transport (if connection succeeded and streams
* are actually alive and working)
* @return certError boolean
* Flag noting if cert trouble caused the streams to fail
* @return s nsISocketTransport
* Underlying socket transport, in case more details are needed.
*/
let _attemptTransport = Task.async(function*({ host, port, encryption }){
// _attemptConnect only opens the streams. Any failures at that stage
// aborts the connection process immedidately.
let { s, input, output } = _attemptConnect({ host, port, encryption });
// Check if the input stream is alive. If encryption is enabled, we need to
// watch out for cert errors by testing the input stream.
let { alive, certError } = yield _isInputAlive(input);
dumpv("Server cert accepted? " + !certError);
let transport;
if (alive) {
transport = new DebuggerTransport(input, output);
} else {
// Something went wrong, close the streams.
input.close();
output.close();
}
return { transport, certError, 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.
*/
function _attemptConnect({ host, port, encryption }) {
let s;
if (encryption) {
s = socketTransportService.createTransport(["ssl"], 1, host, port, null);
} else {
s = socketTransportService.createTransport(null, 0, host, port, null);
}
// 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);
// 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.
let input;
let output;
try {
input = s.openInputStream(0, 0, 0);
output = s.openOutputStream(0, 0, 0);
} catch(e) {
DevToolsUtils.reportException("_attemptConnect", e);
throw e;
}
return { s, input, output };
}
/**
* Check if the input stream is alive. For an encrypted connection, it may not
* be if the client refuses the server's cert. A cert error is expected on
* first connection to a new host because the cert is self-signed.
*/
function _isInputAlive(input) {
let deferred = promise.defer();
input.asyncWait({
onInputStreamReady(stream) {
try {
stream.available();
deferred.resolve({ alive: true });
} catch (e) {
try {
// getErrorClass may throw if you pass a non-NSS error
let errorClass = nssErrorsService.getErrorClass(e.result);
if (errorClass === Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
deferred.resolve({ certError: true });
} else {
deferred.reject(e);
}
} catch (nssErr) {
deferred.reject(e);
}
}
}
}, 0, 0, Services.tm.currentThread);
return deferred.promise;
}
/**
* To allow the connection to proceed with self-signed cert, we store a cert
* override. This implies that we take on the burden of authentication for
* these connections.
*/
function _storeCertOverride(s, host, port) {
let cert = s.securityInfo.QueryInterface(Ci.nsISSLStatusProvider)
.SSLStatus.serverCert;
let overrideBits = Ci.nsICertOverrideService.ERROR_UNTRUSTED |
Ci.nsICertOverrideService.ERROR_MISMATCH;
certOverrideService.rememberValidityOverride(host, port, cert, overrideBits,
true /* temporary */);
}
/**
* Creates a new socket listener for remote connections to the DebuggerServer.
* This helps contain and organize the parts of the server that may differ or
* are particular to one given listener mechanism vs. another.
*/
function SocketListener() {}
/**
* Prompt the user to accept or decline the incoming connection. This is the
* default implementation that products embedding the debugger server may
* choose to override. A separate security handler can be specified for each
* socket via |allowConnection| on a socket listener instance.
*
* @return true if the connection should be permitted, false otherwise
*/
SocketListener.defaultAllowConnection = () => {
let bundle = Services.strings.createBundle(DBG_STRINGS_URI);
let title = bundle.GetStringFromName("remoteIncomingPromptTitle");
let msg = bundle.GetStringFromName("remoteIncomingPromptMessage");
let disableButton = bundle.GetStringFromName("remoteIncomingPromptDisable");
let prompt = Services.prompt;
let 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;
let result = prompt.confirmEx(null, title, msg, flags, null, null,
disableButton, null, { value: false });
if (result === 0) {
return true;
}
if (result === 2) {
DebuggerServer.closeAllListeners();
Services.prefs.setBoolPref("devtools.debugger.remote-enabled", false);
}
return false;
};
SocketListener.prototype = {
/* Socket Options */
/**
* 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.
*/
portOrPath: null,
/**
* Prompt the user to accept or decline the incoming connection. The default
* implementation is used unless this is overridden on a particular socket
* listener instance.
*
* @return true if the connection should be permitted, false otherwise
*/
allowConnection: SocketListener.defaultAllowConnection,
/**
* Controls whether this listener is announced via the service discovery
* mechanism.
*/
discoverable: false,
/**
* Controls whether this listener's transport uses encryption.
*/
encryption: false,
/**
* Validate that all options have been set to a supported configuration.
*/
_validateOptions: function() {
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: function() {
this._validateOptions();
DebuggerServer._addListener(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;
}
let self = this;
return Task.spawn(function*() {
let backlog = 4;
self._socket = self._createSocketInstance();
if (self.isPortBased) {
let port = Number(self.portOrPath);
self._socket.initSpecialConnection(port, flags, backlog);
} else {
let file = nsFile(self.portOrPath);
if (file.exists()) {
file.remove(false);
}
self._socket.initWithFilename(file, parseInt("666", 8), backlog);
}
yield self._setAdditionalSocketOptions();
self._socket.asyncListen(self);
dumpn("Socket listening on: " + (self.port || self.portOrPath));
}).then(() => {
if (this.discoverable && this.port) {
discovery.addService("devtools", {
port: this.port,
encryption: this.encryption
});
}
}).catch(e => {
dumpn("Could not start debugging listener on '" + this.portOrPath +
"': " + e);
this.close();
});
},
_createSocketInstance: function() {
if (this.encryption) {
return Cc["@mozilla.org/network/tls-server-socket;1"]
.createInstance(Ci.nsITLSServerSocket);
}
return Cc["@mozilla.org/network/server-socket;1"]
.createInstance(Ci.nsIServerSocket);
},
_setAdditionalSocketOptions: Task.async(function*() {
if (this.encryption) {
this._socket.serverCert = yield cert.local.getOrCreate();
this._socket.setSessionCache(false);
this._socket.setSessionTickets(false);
let requestCert = Ci.nsITLSServerSocket.REQUEST_NEVER;
this._socket.setRequestClientCertificate(requestCert);
}
}),
/**
* Closes the SocketListener. Notifies the server to remove the listener from
* the set of active SocketListeners.
*/
close: function() {
if (this.discoverable && this.port) {
discovery.removeService("devtools");
}
if (this._socket) {
this._socket.close();
this._socket = null;
}
DebuggerServer._removeListener(this);
},
/**
* 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;
},
// nsIServerSocketListener implementation
onSocketAccepted:
DevToolsUtils.makeInfallible(function(socket, socketTransport) {
if (this.encryption) {
new SecurityObserver(socketTransport);
}
if (Services.prefs.getBoolPref("devtools.debugger.prompt-connection") &&
!this.allowConnection()) {
return;
}
dumpn("New debugging connection on " +
socketTransport.host + ":" + socketTransport.port);
let input = socketTransport.openInputStream(0, 0, 0);
let output = socketTransport.openOutputStream(0, 0, 0);
let transport = new DebuggerTransport(input, output);
DebuggerServer._onConnection(transport);
}, "SocketListener.onSocketAccepted"),
onStopListening: function(socket, status) {
dumpn("onStopListening, status: " + status);
}
};
// Client must complete TLS handshake within this window (ms)
loader.lazyGetter(this, "HANDSHAKE_TIMEOUT", () => {
return Services.prefs.getIntPref("devtools.remote.tls-handshake-timeout");
});
function SecurityObserver(socketTransport) {
this.socketTransport = socketTransport;
let connectionInfo = socketTransport.securityInfo
.QueryInterface(Ci.nsITLSServerConnectionInfo);
connectionInfo.setSecurityObserver(this);
this._handshakeTimeout = setTimeout(this._onHandshakeTimeout.bind(this),
HANDSHAKE_TIMEOUT);
}
SecurityObserver.prototype = {
_onHandshakeTimeout() {
dumpv("Client failed to complete handshake");
this.destroy(Cr.NS_ERROR_NET_TIMEOUT);
},
// nsITLSServerSecurityObserver implementation
onHandshakeDone(socket, clientStatus) {
clearTimeout(this._handshakeTimeout);
dumpv("TLS version: " + clientStatus.tlsVersionUsed.toString(16));
dumpv("TLS cipher: " + clientStatus.cipherName);
dumpv("TLS key length: " + clientStatus.keyLength);
dumpv("TLS MAC length: " + clientStatus.macLength);
/*
* TODO: These rules should be really be set on the TLS socket directly, but
* this would need more platform work to expose it via XPCOM.
*
* Server *will* send hello packet when any rules below are not met, but the
* socket then closes after that.
*
* Enforcing cipher suites here would be a bad idea, as we want TLS
* cipher negotiation to work correctly. The server already allows only
* Gecko's normal set of cipher suites.
*/
if (clientStatus.tlsVersionUsed != Ci.nsITLSClientStatus.TLS_VERSION_1_2) {
this.destroy(Cr.NS_ERROR_CONNECTION_REFUSED);
}
},
destroy(result) {
clearTimeout(this._handshakeTimeout);
let connectionInfo = this.socketTransport.securityInfo
.QueryInterface(Ci.nsITLSServerConnectionInfo);
connectionInfo.setSecurityObserver(null);
this.socketTransport.close(result);
this.socketTransport = null;
}
};
DebuggerSocket.createListener = function() {
return new SocketListener();
};
exports.DebuggerSocket = DebuggerSocket;