Skip to content

Commit

Permalink
Bug 1720098 - [remote] Check websocket handshake requests are from lo…
Browse files Browse the repository at this point in the history
…calhost r=webdriver-reviewers,jgraham,whimboo

Differential Revision: https://phabricator.services.mozilla.com/D132561
  • Loading branch information
juliandescottes committed Dec 14, 2021
1 parent f13595a commit 59fac1c
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 1 deletion.
9 changes: 9 additions & 0 deletions remote/cdp/test/browser/head.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ const { RemoteAgentError } = ChromeUtils.import(
"chrome://remote/content/cdp/Error.jsm"
);

const { allowNullOrigin } = ChromeUtils.import(
"chrome://remote/content/server/WebSocketHandshake.jsm"
);
// The handshake request created by the browser mochitests contains an origin
// header, which is currently not supported. This origin is a string "null".
// Explicitly allow such an origin for the duration of the test.
allowNullOrigin(true);
registerCleanupFunction(() => allowNullOrigin(false));

const TIMEOUT_MULTIPLIER = SpecialPowers.isDebugBuild ? 4 : 1;
const TIMEOUT_EVENTS = 1000 * TIMEOUT_MULTIPLIER;

Expand Down
64 changes: 63 additions & 1 deletion remote/server/WebSocketHandshake.jsm
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

"use strict";

var EXPORTED_SYMBOLS = ["WebSocketHandshake"];
var EXPORTED_SYMBOLS = ["allowNullOrigin", "WebSocketHandshake"];

// This file is an XPCOM service-ified copy of ../devtools/server/socket/websocket-server.js.

Expand All @@ -15,7 +15,10 @@ const { XPCOMUtils } = ChromeUtils.import(
);

XPCOMUtils.defineLazyModuleGetters(this, {
Services: "resource://gre/modules/Services.jsm",

executeSoon: "chrome://remote/content/shared/Sync.jsm",
RemoteAgent: "chrome://remote/content/components/RemoteAgent.jsm",
});

XPCOMUtils.defineLazyGetter(this, "CryptoHash", () => {
Expand All @@ -29,6 +32,16 @@ XPCOMUtils.defineLazyGetter(this, "threadManager", () => {
// TODO(ato): Merge this with httpd.js so that we can respond to both HTTP/1.1
// as well as WebSocket requests on the same server.

// Well-known localhost loopback addresses.
const LOOPBACKS = ["localhost", "127.0.0.1", "[::1]"];

// This should only be used by the CDP browser mochitests which create a
// websocket handshake with a non-null origin.
let nullOriginAllowed = false;
function allowNullOrigin(allowed) {
nullOriginAllowed = allowed;
}

/**
* Write a string of bytes to async output stream
* and return promise that resolves once all data has been written.
Expand Down Expand Up @@ -74,11 +87,60 @@ function writeHttpResponse(output, headers, 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;
}

/**
* Process the WebSocket handshake headers and return the key to be sent in
* Sec-WebSocket-Accept response header.
*/
function processRequest({ requestLine, headers }) {
const origin = headers.get("origin");

// A "null" origin is exceptionally allowed in browser mochitests.
const isTestOrigin = origin === "null" && nullOriginAllowed;
if (headers.has("origin") && !isTestOrigin) {
throw new Error(
`The handshake request has incorrect Host header ${origin}`
);
}

const hostHeader = headers.get("host");

let hostUri, host, port;
try {
// Might throw both when calling newURI or when accessing the host/port.
hostUri = Services.io.newURI(`https://${hostHeader}`);
({ host, port } = hostUri);
} catch (e) {
throw new Error(
`The handshake request Host header must be a well-formed host: ${hostHeader}`
);
}

const isHostnameValid = LOOPBACKS.includes(host) || isIPAddress(hostUri);
// For nsIURI a port value of -1 corresponds to the protocol's default port.
const isPortValid = port === -1 || port == RemoteAgent.port;
if (!isHostnameValid || !isPortValid) {
throw new Error(
`The handshake request has incorrect Host header ${hostHeader}`
);
}

const method = requestLine.split(" ")[0];
if (method !== "GET") {
throw new Error("The handshake request must use GET method");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[websocket_upgrade.py]
disabled:
if release_or_beta: https://bugzilla.mozilla.org/show_bug.cgi?id=1712902

115 changes: 115 additions & 0 deletions testing/web-platform/mozilla/tests/webdriver/bidi/websocket_upgrade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import pytest

from http.client import HTTPConnection


def put_required_headers(conn):
conn.putheader("Connection", "upgrade")
conn.putheader("Upgrade", "websocket")
conn.putheader("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
conn.putheader("Sec-WebSocket-Version", "13")


@pytest.mark.parametrize(
"hostname, port_type, status",
[
# Valid hosts
("localhost", "remote_agent_port", 101),
("localhost", "default_port", 101),
("127.0.0.1", "remote_agent_port", 101),
("127.0.0.1", "default_port", 101),
("[::1]", "remote_agent_port", 101),
("[::1]", "default_port", 101),
("192.168.8.1", "remote_agent_port", 101),
("192.168.8.1", "default_port", 101),
("[fdf8:f535:82e4::53]", "remote_agent_port", 101),
("[fdf8:f535:82e4::53]", "default_port", 101),
# Invalid hosts
("mozilla.org", "remote_agent_port", 400),
("mozilla.org", "wrong_port", 400),
("mozilla.org", "default_port", 400),
("localhost", "wrong_port", 400),
("127.0.0.1", "wrong_port", 400),
("[::1]", "wrong_port", 400),
("192.168.8.1", "wrong_port", 400),
("[fdf8:f535:82e4::53]", "wrong_port", 400),
],
ids=[
# Valid hosts
"localhost with same port as RemoteAgent",
"localhost with default port",
"127.0.0.1 (loopback) with same port as RemoteAgent",
"127.0.0.1 (loopback) with default port",
"[::1] (ipv6 loopback) with same port as RemoteAgent",
"[::1] (ipv6 loopback) with default port",
"ipv4 address with same port as RemoteAgent",
"ipv4 address with default port",
"ipv6 address with same port as RemoteAgent",
"ipv6 address with default port",
# Invalid hosts
"random hostname with the same port as RemoteAgent",
"random hostname with a different port than RemoteAgent",
"random hostname with default port",
"localhost with a different port than RemoteAgent",
"127.0.0.1 (loopback) with a different port than RemoteAgent",
"[::1] (ipv6 loopback) with a different port than RemoteAgent",
"ipv4 address with a different port than RemoteAgent",
"ipv6 address with a different port than RemoteAgent",
],
)
@pytest.mark.capabilities({"webSocketUrl": True})
def test_host_header(session, hostname, port_type, status):
websocket_url = session.capabilities["webSocketUrl"]
url = websocket_url.replace("ws:", "http:")
_, _, real_host, path = url.split("/", 3)
_, remote_agent_port = real_host.split(":")

def get_host():
if port_type == "default_port":
return hostname
elif port_type == "remote_agent_port":
return hostname + ":" + remote_agent_port
elif port_type == "wrong_port":
wrong_port = str(int(remote_agent_port) + 1)
return hostname + ":" + wrong_port

conn = HTTPConnection(real_host)

conn.putrequest("GET", url, skip_host=True)

conn.putheader("Host", get_host())
put_required_headers(conn)
conn.endheaders()

response = conn.getresponse()

assert response.status == status


@pytest.mark.parametrize(
"origin, status",
[
(None, 101),
("", 400),
("sometext", 400),
("http://localhost:1234", 400),
],
)
@pytest.mark.capabilities({"webSocketUrl": True})
def test_origin_header(session, origin, status):
websocket_url = session.capabilities["webSocketUrl"]
url = websocket_url.replace("ws:", "http:")
_, _, real_host, path = url.split("/", 3)

conn = HTTPConnection(real_host)
conn.putrequest("GET", url)

if origin is not None:
conn.putheader("Origin", origin)

put_required_headers(conn)
conn.endheaders()

response = conn.getresponse()

assert response.status == status

0 comments on commit 59fac1c

Please sign in to comment.