gecko-dev/remote/server/WebSocketHandshake.sys.mjs

316 lines
8.7 KiB
JavaScript

/* 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(
() => {
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 };