Bug 1793861 [wpt PR 36150] - Update wpt metadata, a=testonly
[gecko.git] / remote / server / WebSocketHandshake.sys.mjs
blobcb060885d5ae5be69e891e8cdd719b21e560da7a
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 // This file is an XPCOM service-ified copy of ../devtools/server/socket/websocket-server.js.
7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
9 const CC = Components.Constructor;
11 const lazy = {};
13 ChromeUtils.defineESModuleGetters(lazy, {
14   executeSoon: "chrome://remote/content/shared/Sync.sys.mjs",
15   Log: "chrome://remote/content/shared/Log.sys.mjs",
16   RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs",
17 });
19 XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
21 XPCOMUtils.defineLazyGetter(lazy, "CryptoHash", () => {
22   return CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString");
23 });
25 XPCOMUtils.defineLazyGetter(lazy, "threadManager", () => {
26   return Cc["@mozilla.org/thread-manager;1"].getService();
27 });
29 /**
30  * Allowed origins are exposed through 2 separate getters because while most
31  * of the values should be valid URIs, `null` is also a valid origin and cannot
32  * be converted to a URI. Call sites interested in checking for null should use
33  * `allowedOrigins`, those interested in URIs should use `allowedOriginURIs`.
34  */
35 XPCOMUtils.defineLazyGetter(lazy, "allowedOrigins", () =>
36   lazy.RemoteAgent.allowOrigins !== null ? lazy.RemoteAgent.allowOrigins : []
39 XPCOMUtils.defineLazyGetter(lazy, "allowedOriginURIs", () => {
40   return lazy.allowedOrigins
41     .map(origin => {
42       try {
43         const originURI = Services.io.newURI(origin);
44         // Make sure to read host/port/scheme as those getters could throw for
45         // invalid URIs.
46         return {
47           host: originURI.host,
48           port: originURI.port,
49           scheme: originURI.scheme,
50         };
51       } catch (e) {
52         return null;
53       }
54     })
55     .filter(uri => uri !== null);
56 });
58 /**
59  * Write a string of bytes to async output stream
60  * and return promise that resolves once all data has been written.
61  * Doesn't do any UTF-16/UTF-8 conversion.
62  * The string is treated as an array of bytes.
63  */
64 function writeString(output, data) {
65   return new Promise((resolve, reject) => {
66     const wait = () => {
67       if (data.length === 0) {
68         resolve();
69         return;
70       }
72       output.asyncWait(
73         stream => {
74           try {
75             const written = output.write(data, data.length);
76             data = data.slice(written);
77             wait();
78           } catch (ex) {
79             reject(ex);
80           }
81         },
82         0,
83         0,
84         lazy.threadManager.currentThread
85       );
86     };
88     wait();
89   });
92 /**
93  * Write HTTP response with headers (array of strings) and body
94  * to async output stream.
95  */
96 function writeHttpResponse(output, headers, body = "") {
97   headers.push(`Content-Length: ${body.length}`);
99   const s = headers.join("\r\n") + `\r\n\r\n${body}`;
100   return writeString(output, s);
104  * Check if the provided URI's host is an IP address.
106  * @param {nsIURI} uri
107  *     The URI to check.
108  * @return {boolean}
109  */
110 function isIPAddress(uri) {
111   try {
112     // getBaseDomain throws an explicit error if the uri host is an IP address.
113     Services.eTLD.getBaseDomain(uri);
114   } catch (e) {
115     return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS;
116   }
117   return false;
120 function isHostValid(hostHeader) {
121   try {
122     // Might throw both when calling newURI or when accessing the host/port.
123     const hostUri = Services.io.newURI(`https://${hostHeader}`);
124     const { host, port } = hostUri;
125     const isHostnameValid =
126       isIPAddress(hostUri) || lazy.RemoteAgent.allowHosts.includes(host);
127     // For nsIURI a port value of -1 corresponds to the protocol's default port.
128     const isPortValid = [-1, lazy.RemoteAgent.port].includes(port);
129     return isHostnameValid && isPortValid;
130   } catch (e) {
131     return false;
132   }
135 function isOriginValid(originHeader) {
136   if (originHeader === undefined) {
137     // Always accept no origin header.
138     return true;
139   }
141   // Special case "null" origins, used for privacy sensitive or opaque origins.
142   if (originHeader === "null") {
143     return lazy.allowedOrigins.includes("null");
144   }
146   try {
147     // Extract the host, port and scheme from the provided origin header.
148     const { host, port, scheme } = Services.io.newURI(originHeader);
149     // Check if any allowed origin matches the provided host, port and scheme.
150     return lazy.allowedOriginURIs.some(
151       uri => uri.host === host && uri.port === port && uri.scheme === scheme
152     );
153   } catch (e) {
154     // Reject invalid origin headers
155     return false;
156   }
160  * Process the WebSocket handshake headers and return the key to be sent in
161  * Sec-WebSocket-Accept response header.
162  */
163 function processRequest({ requestLine, headers }) {
164   if (!isOriginValid(headers.get("origin"))) {
165     lazy.logger.debug(
166       `Incorrect Origin header, allowed origins: [${lazy.allowedOrigins}]`
167     );
168     throw new Error(
169       `The handshake request has incorrect Origin header ${headers.get(
170         "origin"
171       )}`
172     );
173   }
175   if (!isHostValid(headers.get("host"))) {
176     lazy.logger.debug(
177       `Incorrect Host header, allowed hosts: [${lazy.RemoteAgent.allowHosts}]`
178     );
179     throw new Error(
180       `The handshake request has incorrect Host header ${headers.get("host")}`
181     );
182   }
184   const method = requestLine.split(" ")[0];
185   if (method !== "GET") {
186     throw new Error("The handshake request must use GET method");
187   }
189   const upgrade = headers.get("upgrade");
190   if (!upgrade || upgrade.toLowerCase() !== "websocket") {
191     throw new Error(
192       `The handshake request has incorrect Upgrade header: ${upgrade}`
193     );
194   }
196   const connection = headers.get("connection");
197   if (
198     !connection ||
199     !connection
200       .split(",")
201       .map(t => t.trim().toLowerCase())
202       .includes("upgrade")
203   ) {
204     throw new Error("The handshake request has incorrect Connection header");
205   }
207   const version = headers.get("sec-websocket-version");
208   if (!version || version !== "13") {
209     throw new Error(
210       "The handshake request must have Sec-WebSocket-Version: 13"
211     );
212   }
214   // Compute the accept key
215   const key = headers.get("sec-websocket-key");
216   if (!key) {
217     throw new Error(
218       "The handshake request must have a Sec-WebSocket-Key header"
219     );
220   }
222   return { acceptKey: computeKey(key) };
225 function computeKey(key) {
226   const str = `${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`;
227   const data = Array.from(str, ch => ch.charCodeAt(0));
228   const hash = new lazy.CryptoHash("sha1");
229   hash.update(data, data.length);
230   return hash.finish(true);
234  * Perform the server part of a WebSocket opening handshake
235  * on an incoming connection.
236  */
237 async function serverHandshake(request, output) {
238   try {
239     // Check and extract info from the request
240     const { acceptKey } = processRequest(request);
242     // Send response headers
243     await writeHttpResponse(output, [
244       "HTTP/1.1 101 Switching Protocols",
245       "Server: httpd.js",
246       "Upgrade: websocket",
247       "Connection: Upgrade",
248       `Sec-WebSocket-Accept: ${acceptKey}`,
249     ]);
250   } catch (error) {
251     // Send error response in case of error
252     await writeHttpResponse(
253       output,
254       [
255         "HTTP/1.1 400 Bad Request",
256         "Server: httpd.js",
257         "Content-Type: text/plain",
258       ],
259       error.message
260     );
262     throw error;
263   }
266 async function createWebSocket(transport, input, output) {
267   const transportProvider = {
268     setListener(upgradeListener) {
269       // onTransportAvailable callback shouldn't be called synchronously
270       lazy.executeSoon(() => {
271         upgradeListener.onTransportAvailable(transport, input, output);
272       });
273     },
274   };
276   return new Promise((resolve, reject) => {
277     const socket = WebSocket.createServerWebSocket(
278       null,
279       [],
280       transportProvider,
281       ""
282     );
283     socket.addEventListener("close", () => {
284       input.close();
285       output.close();
286     });
288     socket.onopen = () => resolve(socket);
289     socket.onerror = err => reject(err);
290   });
293 /** Upgrade an existing HTTP request from httpd.js to WebSocket. */
294 async function upgrade(request, response) {
295   // handle response manually, allowing us to send arbitrary data
296   response._powerSeized = true;
298   const { transport, input, output } = response._connection;
300   lazy.logger.info(
301     `Perform WebSocket upgrade for incoming connection from ${transport.host}:${transport.port}`
302   );
304   const headers = new Map();
305   for (let [key, values] of Object.entries(request._headers._headers)) {
306     headers.set(key, values.join("\n"));
307   }
308   const convertedRequest = {
309     requestLine: `${request.method} ${request.path}`,
310     headers,
311   };
312   await serverHandshake(convertedRequest, output);
314   return createWebSocket(transport, input, output);
317 export const WebSocketHandshake = { upgrade };