"use strict"; const { BaseNodeServer, NodeServer } = ChromeUtils.importESModule( "resource://testing-common/NodeServer.sys.mjs" ); const baseURL = getRootDirectory(gTestPath).replace( "chrome://mochitests/content", "https://example.com" ); // 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); });