Source code

Revision control

Copy as Markdown

Other Tools

Test Info:

"use strict";
const { BaseNodeServer, NodeServer } = ChromeUtils.importESModule(
);
const baseURL = getRootDirectory(gTestPath).replace(
);
// Node-side code: raw TCP server that captures SNI from TLS ClientHello.
/* globals require, global */
class NodeSNIServerCode {
// Extracts the SNI hostname from a TLS ClientHello message.
static extractSNI(buffer) {
if (buffer.length < 5) {
return null;
}
const contentType = buffer[0];
if (contentType !== 0x16) {
return null;
}
const recordLength = buffer.readUInt16BE(3);
if (buffer.length < 5 + recordLength) {
return null;
}
const handshakeType = buffer[5];
if (handshakeType !== 0x01) {
return null;
}
let offset = 5 + 1 + 3 + 2 + 32;
const sessionIdLength = buffer[offset];
offset += 1 + sessionIdLength;
const cipherSuitesLength = buffer.readUInt16BE(offset);
offset += 2 + cipherSuitesLength;
const compressionMethodsLength = buffer[offset];
offset += 1 + compressionMethodsLength;
if (offset + 2 > buffer.length) {
return null;
}
const extensionsLength = buffer.readUInt16BE(offset);
offset += 2;
const extensionsEnd = offset + extensionsLength;
while (offset + 4 <= extensionsEnd) {
const extType = buffer.readUInt16BE(offset);
const extLength = buffer.readUInt16BE(offset + 2);
offset += 4;
if (extType === 0x0000) {
const sniListLength = buffer.readUInt16BE(offset);
let sniOffset = offset + 2;
const sniEnd = offset + sniListLength + 2;
while (sniOffset + 3 <= sniEnd) {
const nameType = buffer[sniOffset];
const nameLength = buffer.readUInt16BE(sniOffset + 1);
sniOffset += 3;
if (nameType === 0x00) {
return buffer
.slice(sniOffset, sniOffset + nameLength)
.toString("ascii");
}
sniOffset += nameLength;
}
}
offset += extLength;
}
return null;
}
static async startServer(port) {
const net = require("net");
global.sniValues = [];
global.connectionCount = 0;
global.server = net.createServer(socket => {
global.connectionCount++;
socket.once("data", data => {
const sni = NodeSNIServerCode.extractSNI(data);
console.log(`sni = ${sni}`);
if (sni) {
global.sniValues.push(sni);
}
});
socket.on("error", () => {});
});
await new Promise(resolve => global.server.listen(port, resolve));
return global.server.address().port;
}
}
// Test-side server class extending BaseNodeServer.
class NodeSNIServer extends BaseNodeServer {
async start(port = 0) {
this.processId = await NodeServer.fork();
await this.execute(NodeSNIServerCode);
this._port = await this.execute(`NodeSNIServerCode.startServer(${port})`);
}
async stop() {
if (this.processId) {
await this.execute(`global.server.close(() => {})`);
await NodeServer.kill(this.processId);
this.processId = undefined;
}
}
async connectionCount() {
return this.execute(`global.connectionCount`);
}
async sniValues() {
return this.execute(`global.sniValues`);
}
}
function waitForFetchComplete(port) {
let targetURL = `https://localhost:${port}/`;
return new Promise(resolve => {
let observer = {
observe(subject) {
let channel = subject.QueryInterface(Ci.nsIChannel);
if (channel.URI.spec !== targetURL) {
return;
}
Services.obs.removeObserver(observer, "http-on-stop-request");
resolve(channel.status);
},
};
Services.obs.addObserver(observer, "http-on-stop-request");
});
}
let gServer;
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
set: [
["network.lna.blocking", true],
["network.proxy.allow_hijacking_localhost", false],
["network.lna.address_space.public.override", "127.0.0.1:4443"],
],
});
gServer = new NodeSNIServer();
await gServer.start();
info(`SNI capture server listening on port ${gServer.port()}`);
registerCleanupFunction(async () => {
await gServer.stop();
});
});
// Test 1: Verify that blocking the LNA prompt does not leak SNI.
add_task(async function test_lna_block_no_sni_leak() {
Services.obs.notifyObservers(null, "testonly-reload-permissions-from-disk");
Services.perms.removeAll();
is(await gServer.connectionCount(), 0, "No connections before loading page");
let promptPromise = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
const tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
`${baseURL}page_fetch_localhost_https.html?port=${gServer.port()}`
);
await promptPromise;
let popup = PopupNotifications.getNotification(
"loopback-network",
tab.linkedBrowser
);
ok(popup, "LNA permission prompt should appear for https://localhost fetch");
Assert.equal(
await gServer.connectionCount(),
1,
"Only 1 TCP connection should have been made before LNA prompt response"
);
Assert.deepEqual(
await gServer.sniValues(),
[],
"No SNI values should be captured before the user responds to the LNA prompt"
);
let notification = popup?.owner?.panel?.childNodes?.[0];
ok(notification, "Notification popup element is available");
let fetchDone = waitForFetchComplete(gServer.port());
notification.secondaryButton.doCommand();
await fetchDone;
Assert.equal(
await gServer.connectionCount(),
1,
"No new TCP connections after blocking the LNA prompt"
);
Assert.deepEqual(
await gServer.sniValues(),
[],
"No SNI values should be captured after the user rejects the LNA prompt"
);
gBrowser.removeTab(tab);
});
// Test 2: After clearing permissions, verify re-prompt and that accepting
// allows the SNI to reach the server.
add_task(async function test_lna_accept_receives_sni() {
Services.obs.notifyObservers(null, "testonly-reload-permissions-from-disk");
Services.perms.removeAll();
// Reset server counters.
await gServer.execute(`global.connectionCount = 0`);
await gServer.execute(`global.sniValues = []`);
let promptPromise = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
const tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
`${baseURL}page_fetch_localhost_https.html?port=${gServer.port()}`
);
await promptPromise;
let popup = PopupNotifications.getNotification(
"loopback-network",
tab.linkedBrowser
);
ok(popup, "LNA permission prompt should appear again after permission reset");
Assert.equal(
await gServer.connectionCount(),
1,
"Only 1 TCP connection should have been made before LNA prompt response"
);
Assert.deepEqual(
await gServer.sniValues(),
[],
"No SNI values should be captured before accepting the LNA prompt"
);
// Accept the prompt.
let notification = popup?.owner?.panel?.childNodes?.[0];
ok(notification, "Notification popup element is available for accept");
notification.button.doCommand();
// The server is a raw TCP socket, so the TLS handshake will fail after the
// ClientHello is sent. Wait for the SNI to be captured by the server.
await BrowserTestUtils.waitForCondition(
async () => !!(await gServer.sniValues()).length,
"Waiting for SNI value after accepting the LNA prompt"
);
Assert.greater(
(await gServer.sniValues()).length,
0,
"SNI value should be captured after the user accepts the LNA prompt"
);
gBrowser.removeTab(tab);
});