mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-27 06:43:32 +00:00
049e4fc37a
Differential Revision: https://phabricator.services.mozilla.com/D202981
316 lines
8.7 KiB
JavaScript
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 };
|