summaryrefslogtreecommitdiffstats
path: root/remote/server
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--remote/server/README8
-rw-r--r--remote/server/WebSocketHandshake.sys.mjs315
-rw-r--r--remote/server/WebSocketTransport.sys.mjs130
3 files changed, 453 insertions, 0 deletions
diff --git a/remote/server/README b/remote/server/README
new file mode 100644
index 0000000000..00184130d3
--- /dev/null
+++ b/remote/server/README
@@ -0,0 +1,8 @@
+These files provide functionality for serving and responding to HTTP
+requests, and handling WebSocket connections. For this we rely on
+httpd.js and the chrome-only WebSocket.createServerWebSocket function.
+
+Generally speaking, this is all held together with a piece of string.
+It is a known problem that we do not have a high-quality HTTPD
+implementation in central, and we’d like to move away from using
+any of this code.
diff --git a/remote/server/WebSocketHandshake.sys.mjs b/remote/server/WebSocketHandshake.sys.mjs
new file mode 100644
index 0000000000..f137b484ae
--- /dev/null
+++ b/remote/server/WebSocketHandshake.sys.mjs
@@ -0,0 +1,315 @@
+/* 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 an XPCOM service-ified copy of ../devtools/server/socket/websocket-server.js.
+
+const CC = Components.Constructor;
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ executeSoon: "chrome://remote/content/shared/Sync.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+ChromeUtils.defineLazyGetter(lazy, "CryptoHash", () => {
+ return CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString");
+});
+
+ChromeUtils.defineLazyGetter(lazy, "threadManager", () => {
+ return Cc["@mozilla.org/thread-manager;1"].getService();
+});
+
+/**
+ * Allowed origins are exposed through 2 separate getters because while most
+ * of the values should be valid URIs, `null` is also a valid origin and cannot
+ * be converted to a URI. Call sites interested in checking for null should use
+ * `allowedOrigins`, those interested in URIs should use `allowedOriginURIs`.
+ */
+ChromeUtils.defineLazyGetter(lazy, "allowedOrigins", () =>
+ lazy.RemoteAgent.allowOrigins !== null ? lazy.RemoteAgent.allowOrigins : []
+);
+
+ChromeUtils.defineLazyGetter(lazy, "allowedOriginURIs", () => {
+ return lazy.allowedOrigins
+ .map(origin => {
+ try {
+ const originURI = Services.io.newURI(origin);
+ // Make sure to read host/port/scheme as those getters could throw for
+ // invalid URIs.
+ return {
+ host: originURI.host,
+ port: originURI.port,
+ scheme: originURI.scheme,
+ };
+ } catch (e) {
+ return null;
+ }
+ })
+ .filter(uri => uri !== null);
+});
+
+/**
+ * Write a string of bytes to async output stream
+ * and return promise that resolves once all data has been written.
+ * Doesn't do any UTF-16/UTF-8 conversion.
+ * The string is treated as an array of bytes.
+ */
+function writeString(output, data) {
+ return new Promise((resolve, reject) => {
+ const wait = () => {
+ if (data.length === 0) {
+ resolve();
+ return;
+ }
+
+ output.asyncWait(
+ stream => {
+ try {
+ const written = output.write(data, data.length);
+ data = data.slice(written);
+ wait();
+ } catch (ex) {
+ reject(ex);
+ }
+ },
+ 0,
+ 0,
+ lazy.threadManager.currentThread
+ );
+ };
+
+ wait();
+ });
+}
+
+/**
+ * Write HTTP response with headers (array of strings) and body
+ * to async output stream.
+ */
+function writeHttpResponse(output, headers, body = "") {
+ headers.push(`Content-Length: ${body.length}`);
+
+ const s = headers.join("\r\n") + `\r\n\r\n${body}`;
+ return writeString(output, s);
+}
+
+/**
+ * Check if the provided URI's host is an IP address.
+ *
+ * @param {nsIURI} uri
+ * The URI to check.
+ * @returns {boolean}
+ */
+function isIPAddress(uri) {
+ try {
+ // getBaseDomain throws an explicit error if the uri host is an IP address.
+ Services.eTLD.getBaseDomain(uri);
+ } catch (e) {
+ return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS;
+ }
+ return false;
+}
+
+function isHostValid(hostHeader) {
+ try {
+ // Might throw both when calling newURI or when accessing the host/port.
+ const hostUri = Services.io.newURI(`https://${hostHeader}`);
+ const { host, port } = hostUri;
+ const isHostnameValid =
+ isIPAddress(hostUri) || lazy.RemoteAgent.allowHosts.includes(host);
+ // For nsIURI a port value of -1 corresponds to the protocol's default port.
+ const isPortValid = [-1, lazy.RemoteAgent.port].includes(port);
+ return isHostnameValid && isPortValid;
+ } catch (e) {
+ return false;
+ }
+}
+
+function isOriginValid(originHeader) {
+ if (originHeader === undefined) {
+ // Always accept no origin header.
+ return true;
+ }
+
+ // Special case "null" origins, used for privacy sensitive or opaque origins.
+ if (originHeader === "null") {
+ return lazy.allowedOrigins.includes("null");
+ }
+
+ try {
+ // Extract the host, port and scheme from the provided origin header.
+ const { host, port, scheme } = Services.io.newURI(originHeader);
+ // Check if any allowed origin matches the provided host, port and scheme.
+ return lazy.allowedOriginURIs.some(
+ uri => uri.host === host && uri.port === port && uri.scheme === scheme
+ );
+ } catch (e) {
+ // Reject invalid origin headers
+ return false;
+ }
+}
+
+/**
+ * Process the WebSocket handshake headers and return the key to be sent in
+ * Sec-WebSocket-Accept response header.
+ */
+function processRequest({ requestLine, headers }) {
+ if (!isOriginValid(headers.get("origin"))) {
+ lazy.logger.debug(
+ `Incorrect Origin header, allowed origins: [${lazy.allowedOrigins}]`
+ );
+ throw new Error(
+ `The handshake request has incorrect Origin header ${headers.get(
+ "origin"
+ )}`
+ );
+ }
+
+ if (!isHostValid(headers.get("host"))) {
+ lazy.logger.debug(
+ `Incorrect Host header, allowed hosts: [${lazy.RemoteAgent.allowHosts}]`
+ );
+ throw new Error(
+ `The handshake request has incorrect Host header ${headers.get("host")}`
+ );
+ }
+
+ const method = requestLine.split(" ")[0];
+ if (method !== "GET") {
+ throw new Error("The handshake request must use GET method");
+ }
+
+ const upgrade = headers.get("upgrade");
+ if (!upgrade || upgrade.toLowerCase() !== "websocket") {
+ throw new Error(
+ `The handshake request has incorrect Upgrade header: ${upgrade}`
+ );
+ }
+
+ const connection = headers.get("connection");
+ if (
+ !connection ||
+ !connection
+ .split(",")
+ .map(t => t.trim().toLowerCase())
+ .includes("upgrade")
+ ) {
+ throw new Error("The handshake request has incorrect Connection header");
+ }
+
+ const version = headers.get("sec-websocket-version");
+ if (!version || version !== "13") {
+ throw new Error(
+ "The handshake request must have Sec-WebSocket-Version: 13"
+ );
+ }
+
+ // Compute the accept key
+ const key = headers.get("sec-websocket-key");
+ if (!key) {
+ throw new Error(
+ "The handshake request must have a Sec-WebSocket-Key header"
+ );
+ }
+
+ return { acceptKey: computeKey(key) };
+}
+
+function computeKey(key) {
+ const str = `${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`;
+ const data = Array.from(str, ch => ch.charCodeAt(0));
+ const hash = new lazy.CryptoHash("sha1");
+ hash.update(data, data.length);
+ return hash.finish(true);
+}
+
+/**
+ * Perform the server part of a WebSocket opening handshake
+ * on an incoming connection.
+ */
+async function serverHandshake(request, output) {
+ try {
+ // Check and extract info from the request
+ const { acceptKey } = processRequest(request);
+
+ // Send response headers
+ await writeHttpResponse(output, [
+ "HTTP/1.1 101 Switching Protocols",
+ "Server: httpd.js",
+ "Upgrade: websocket",
+ "Connection: Upgrade",
+ `Sec-WebSocket-Accept: ${acceptKey}`,
+ ]);
+ } catch (error) {
+ // Send error response in case of error
+ await writeHttpResponse(
+ output,
+ [
+ "HTTP/1.1 400 Bad Request",
+ "Server: httpd.js",
+ "Content-Type: text/plain",
+ ],
+ error.message
+ );
+
+ throw error;
+ }
+}
+
+async function createWebSocket(transport, input, output) {
+ const transportProvider = {
+ setListener(upgradeListener) {
+ // onTransportAvailable callback shouldn't be called synchronously
+ lazy.executeSoon(() => {
+ upgradeListener.onTransportAvailable(transport, input, output);
+ });
+ },
+ };
+
+ return new Promise((resolve, reject) => {
+ const socket = WebSocket.createServerWebSocket(
+ null,
+ [],
+ transportProvider,
+ ""
+ );
+ socket.addEventListener("close", () => {
+ input.close();
+ output.close();
+ });
+
+ socket.onopen = () => resolve(socket);
+ socket.onerror = err => reject(err);
+ });
+}
+
+/** Upgrade an existing HTTP request from httpd.js to WebSocket. */
+async function upgrade(request, response) {
+ // handle response manually, allowing us to send arbitrary data
+ response._powerSeized = true;
+
+ const { transport, input, output } = response._connection;
+
+ lazy.logger.info(
+ `Perform WebSocket upgrade for incoming connection from ${transport.host}:${transport.port}`
+ );
+
+ const headers = new Map();
+ for (let [key, values] of Object.entries(request._headers._headers)) {
+ headers.set(key, values.join("\n"));
+ }
+ const convertedRequest = {
+ requestLine: `${request.method} ${request.path}`,
+ headers,
+ };
+ await serverHandshake(convertedRequest, output);
+
+ return createWebSocket(transport, input, output);
+}
+
+export const WebSocketHandshake = { upgrade };
diff --git a/remote/server/WebSocketTransport.sys.mjs b/remote/server/WebSocketTransport.sys.mjs
new file mode 100644
index 0000000000..0ac6a6399a
--- /dev/null
+++ b/remote/server/WebSocketTransport.sys.mjs
@@ -0,0 +1,130 @@
+/* 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 is an XPCOM service-ified copy of ../devtools/shared/transport/websocket-transport.js.
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+export function WebSocketTransport(socket) {
+ lazy.EventEmitter.decorate(this);
+
+ this.active = false;
+ this.hooks = null;
+ this.socket = socket;
+}
+
+WebSocketTransport.prototype = {
+ ready() {
+ if (this.active) {
+ return;
+ }
+
+ this.socket.addEventListener("message", this);
+ this.socket.addEventListener("close", this);
+
+ this.active = true;
+ },
+
+ send(object) {
+ this.emit("send", object);
+ if (this.socket) {
+ this.socket.send(JSON.stringify(object));
+ }
+ },
+
+ startBulkSend() {
+ throw new Error("Bulk send is not supported by WebSocket transport");
+ },
+
+ /**
+ * Force closing the active connection and WebSocket.
+ */
+ close() {
+ if (!this.socket) {
+ return;
+ }
+ this.emit("close");
+
+ if (this.hooks) {
+ this.hooks.onConnectionClose();
+ }
+
+ this.active = false;
+
+ this.socket.removeEventListener("message", this);
+
+ // Remove the listener that is used when the connection
+ // is closed because we already emitted the 'close' event.
+ // Instead listen for the closing of the WebSocket.
+ this.socket.removeEventListener("close", this);
+ this.socket.addEventListener("close", this.onSocketClose.bind(this));
+
+ // Close socket with code `1000` for a normal closure.
+ this.socket.close(1000);
+ },
+
+ /**
+ * Callback for socket on close event,
+ * it is used in case websocket was closed by the client.
+ */
+ onClose() {
+ if (!this.socket) {
+ return;
+ }
+ this.emit("close");
+
+ if (this.hooks) {
+ this.hooks.onConnectionClose();
+ this.hooks.onSocketClose();
+ this.hooks = null;
+ }
+
+ this.active = false;
+
+ this.socket.removeEventListener("message", this);
+ this.socket.removeEventListener("close", this);
+ this.socket = null;
+ },
+
+ /**
+ * Callback which is called when we can be sure that websocket is closed.
+ */
+ onSocketClose() {
+ this.socket = null;
+
+ if (this.hooks) {
+ this.hooks.onSocketClose();
+ this.hooks = null;
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "message":
+ this.onMessage(event);
+ break;
+ case "close":
+ this.onClose();
+ break;
+ }
+ },
+
+ onMessage({ data }) {
+ if (typeof data !== "string") {
+ throw new Error(
+ "Binary messages are not supported by WebSocket transport"
+ );
+ }
+
+ const object = JSON.parse(data);
+ this.emit("packet", object);
+ if (this.hooks) {
+ this.hooks.onPacket(object);
+ }
+ },
+};