summaryrefslogtreecommitdiffstats
path: root/devtools/server/socket
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/server/socket
parentInitial commit. (diff)
downloadfirefox-esr-upstream.tar.xz
firefox-esr-upstream.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/socket')
-rw-r--r--devtools/server/socket/moz.build11
-rw-r--r--devtools/server/socket/tests/chrome/chrome.ini3
-rw-r--r--devtools/server/socket/tests/chrome/test_websocket-server.html88
-rw-r--r--devtools/server/socket/websocket-server.js250
4 files changed, 352 insertions, 0 deletions
diff --git a/devtools/server/socket/moz.build b/devtools/server/socket/moz.build
new file mode 100644
index 0000000000..1d3766c192
--- /dev/null
+++ b/devtools/server/socket/moz.build
@@ -0,0 +1,11 @@
+# -*- 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.ini"]
+
+DevToolsModules(
+ "websocket-server.js",
+)
diff --git a/devtools/server/socket/tests/chrome/chrome.ini b/devtools/server/socket/tests/chrome/chrome.ini
new file mode 100644
index 0000000000..cfbb612954
--- /dev/null
+++ b/devtools/server/socket/tests/chrome/chrome.ini
@@ -0,0 +1,3 @@
+[DEFAULT]
+tags = devtools
+[test_websocket-server.html]
diff --git a/devtools/server/socket/tests/chrome/test_websocket-server.html b/devtools/server/socket/tests/chrome/test_websocket-server.html
new file mode 100644
index 0000000000..b809aca0a5
--- /dev/null
+++ b/devtools/server/socket/tests/chrome/test_websocket-server.html
@@ -0,0 +1,88 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Mozilla Bug</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");
+ const WebSocketServer = require("devtools/server/socket/websocket-server");
+
+ const ServerSocket = Components.Constructor("@mozilla.org/network/server-socket;1",
+ "nsIServerSocket", "init");
+
+ add_task(async function() {
+ // Create a TCP server on auto-assigned port
+ const server = new ServerSocket(-1, true, -1);
+ ok(server, `Launched WebSocket server on port ${server.port}`);
+
+ let input, output;
+
+ server.asyncListen({
+ async onSocketAccepted(socket, transport) {
+ info("Accepted incoming connection");
+ input = transport.openInputStream(0, 0, 0);
+ output = transport.openOutputStream(0, 0, 0);
+
+ // Perform the WebSocket handshake
+ const webSocket = await WebSocketServer.accept(transport, input, output);
+
+ // Echo the received message back to the sender
+ webSocket.onmessage = ({ data }) => {
+ info("Server received message, echoing back");
+ webSocket.send(data);
+ };
+ },
+
+ onStopListening(socket, status) {
+ info(`Server stopped listening with status: ${status}`);
+ },
+ });
+
+ SimpleTest.registerCleanupFunction(() => {
+ server.close();
+ });
+
+ // Create client connection
+ const client = await new Promise((resolve, reject) => {
+ const socket = new WebSocket(`ws://localhost:${server.port}`);
+ socket.onopen = () => resolve(socket);
+ socket.onerror = reject;
+ });
+ ok(client, `Created WebSocket connection to port ${server.port}`);
+
+ // Create a promise that resolves when the WebSocket closes
+ const closed = new Promise(resolve => {
+ client.onclose = resolve;
+ });
+
+ // Send a message
+ const message = "hello there";
+ client.send(message);
+ info("Sent a message to server");
+ // Check that it was echoed
+ const echoedMessage = await new Promise((resolve, reject) => {
+ client.onmessage = ({ data }) => resolve(data);
+ client.onerror = reject;
+ });
+
+ is(echoedMessage, message, "Echoed message matches");
+
+ // Close the connection
+ client.close();
+ await closed;
+
+ // Prevent leaking the streams by closing them before test ends
+ input.close();
+ output.close();
+ });
+};
+</script>
+</body>
+</html>
diff --git a/devtools/server/socket/websocket-server.js b/devtools/server/socket/websocket-server.js
new file mode 100644
index 0000000000..4236ec2921
--- /dev/null
+++ b/devtools/server/socket/websocket-server.js
@@ -0,0 +1,250 @@
+/* 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 { executeSoon } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ delimitedRead,
+} = require("resource://devtools/shared/transport/stream-utils.js");
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+const threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+
+// Limit the header size to put an upper bound on allocated memory
+const HEADER_MAX_LEN = 8000;
+
+/**
+ * Read a line from async input stream and return promise that resolves to the line once
+ * it has been read. If the line is longer than HEADER_MAX_LEN, will throw error.
+ */
+function readLine(input) {
+ return new Promise((resolve, reject) => {
+ let line = "";
+ const wait = () => {
+ input.asyncWait(
+ stream => {
+ try {
+ const amountToRead = HEADER_MAX_LEN - line.length;
+ line += delimitedRead(input, "\n", amountToRead);
+
+ if (line.endsWith("\n")) {
+ resolve(line.trimRight());
+ return;
+ }
+
+ if (line.length >= HEADER_MAX_LEN) {
+ throw new Error(
+ `Failed to read HTTP header longer than ${HEADER_MAX_LEN} bytes`
+ );
+ }
+
+ wait();
+ } catch (ex) {
+ reject(ex);
+ }
+ },
+ 0,
+ 0,
+ threadManager.currentThread
+ );
+ };
+
+ wait();
+ });
+}
+
+/**
+ * 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,
+ threadManager.currentThread
+ );
+ };
+
+ wait();
+ });
+}
+
+/**
+ * Read HTTP request from async input stream.
+ * @return Request line (string) and Map of header names and values.
+ */
+const readHttpRequest = async function (input) {
+ let requestLine = "";
+ const headers = new Map();
+
+ while (true) {
+ const line = await readLine(input);
+ if (!line.length) {
+ break;
+ }
+
+ if (!requestLine) {
+ requestLine = line;
+ } else {
+ const colon = line.indexOf(":");
+ if (colon == -1) {
+ throw new Error(`Malformed HTTP header: ${line}`);
+ }
+
+ const name = line.slice(0, colon).toLowerCase();
+ const value = line.slice(colon + 1).trim();
+ headers.set(name, value);
+ }
+ }
+
+ return { requestLine, headers };
+};
+
+/**
+ * Write HTTP response (array of strings) to async output stream.
+ */
+function writeHttpResponse(output, response) {
+ const responseString = response.join("\r\n") + "\r\n\r\n";
+ return writeString(output, responseString);
+}
+
+/**
+ * Process the WebSocket handshake headers and return the key to be sent in
+ * Sec-WebSocket-Accept response header.
+ */
+function processRequest({ requestLine, headers }) {
+ const [method, path] = requestLine.split(" ");
+ if (method !== "GET") {
+ throw new Error("The handshake request must use GET method");
+ }
+
+ if (path !== "/") {
+ throw new Error("The handshake request has unknown path");
+ }
+
+ const upgrade = headers.get("upgrade");
+ if (!upgrade || upgrade !== "websocket") {
+ throw new Error("The handshake request has incorrect Upgrade header");
+ }
+
+ const connection = headers.get("connection");
+ if (
+ !connection ||
+ !connection
+ .split(",")
+ .map(t => t.trim())
+ .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 CryptoHash("sha1");
+ hash.update(data, data.length);
+ return hash.finish(true);
+}
+
+/**
+ * Perform the server part of a WebSocket opening handshake on an incoming connection.
+ */
+const serverHandshake = async function (input, output) {
+ // Read the request
+ const request = await readHttpRequest(input);
+
+ try {
+ // Check and extract info from the request
+ const { acceptKey } = processRequest(request);
+
+ // Send response headers
+ await writeHttpResponse(output, [
+ "HTTP/1.1 101 Switching Protocols",
+ "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"]);
+ throw error;
+ }
+};
+
+/**
+ * Accept an incoming WebSocket server connection.
+ * Takes an established nsISocketTransport in the parameters.
+ * Performs the WebSocket handshake and waits for the WebSocket to open.
+ * Returns Promise with a WebSocket ready to send and receive messages.
+ */
+const accept = async function (transport, input, output) {
+ await serverHandshake(input, output);
+
+ const transportProvider = {
+ setListener(upgradeListener) {
+ // The onTransportAvailable callback shouldn't be called synchronously.
+ 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);
+ });
+};
+
+exports.accept = accept;