diff options
Diffstat (limited to 'remote/server')
-rw-r--r-- | remote/server/README | 8 | ||||
-rw-r--r-- | remote/server/WebSocketHandshake.sys.mjs | 317 | ||||
-rw-r--r-- | remote/server/WebSocketTransport.sys.mjs | 86 |
3 files changed, 411 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..cb060885d5 --- /dev/null +++ b/remote/server/WebSocketHandshake.sys.mjs @@ -0,0 +1,317 @@ +/* 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. + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +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", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +XPCOMUtils.defineLazyGetter(lazy, "CryptoHash", () => { + return CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString"); +}); + +XPCOMUtils.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`. + */ +XPCOMUtils.defineLazyGetter(lazy, "allowedOrigins", () => + lazy.RemoteAgent.allowOrigins !== null ? lazy.RemoteAgent.allowOrigins : [] +); + +XPCOMUtils.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. + * @return {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..e2a7183fdb --- /dev/null +++ b/remote/server/WebSocketTransport.sys.mjs @@ -0,0 +1,86 @@ +/* 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"); + }, + + close() { + if (!this.socket) { + return; + } + this.emit("close"); + this.active = false; + + this.socket.removeEventListener("message", this); + this.socket.removeEventListener("close", this); + this.socket.close(); + this.socket = null; + + if (this.hooks) { + this.hooks.onClosed(); + this.hooks = null; + } + }, + + handleEvent(event) { + switch (event.type) { + case "message": + this.onMessage(event); + break; + case "close": + this.close(); + 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); + } + }, +}; |