/* 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/. */ // Verifies the Happy Eyeballs HTTP/1 0-RTT-accepted code path: // * The winning HT is promoted (conn.mTransaction becomes a pass- // through to the real nsHttpTransaction) so no re-Activate / // DispatchTransaction races with the MOZ_ASSERT in the conn mgr. // * The real transaction does NOT re-send its request on the wire // after the server has accepted the early-data bytes. // // We run against ZeroRttAcceptServer — a TLS 1.3 test server that // accepts 0-RTT, reads HTTP requests in full, and fires a callback on // every request it observes on the wire. The test counts callbacks: // one per user-visible fetch. A duplicate request post-handshake // (i.e. the bug this fix guards against) would produce a second // callback on the same connection. "use strict"; const { HttpServer } = ChromeUtils.importESModule( "resource://testing-common/httpd.sys.mjs" ); var { setTimeout } = ChromeUtils.importESModule( "resource://gre/modules/Timer.sys.mjs" ); const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { NodeHTTPServer } = ChromeUtils.importESModule( "resource://testing-common/NodeServer.sys.mjs" ); let callbackServer; // Server splits /callback/request/{early,std} by whether the request // arrived via accepted 0-RTT early data. Track the two counters // separately so tests can assert which path delivered each fetch. let earlyCount = 0; let stdCount = 0; function callbackHandler(metadata) { if (metadata.path === "/callback/request/early") { earlyCount++; } else if (metadata.path === "/callback/request/std") { stdCount++; } } function resetCounts() { earlyCount = 0; stdCount = 0; } add_setup( { skip_if: () => AppConstants.MOZ_SYSTEM_NSS, }, async () => { callbackServer = new HttpServer(); callbackServer.registerPrefixHandler("/callback/", callbackHandler); callbackServer.start(-1); Services.env.set( "MOZ_ZERORTT_ACCEPT_CALLBACK_PORT", callbackServer.identity.primaryPort ); Services.env.set("MOZ_TLS_SERVER_0RTT", "1"); await asyncStartTLSTestServer( "ZeroRttAcceptServer", "../../../security/manager/ssl/tests/unit/test_faulty_server" ); let nssComponent = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent); await nssComponent.asyncClearSSLExternalAndInternalSessionCache(); Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", true); Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0); Services.prefs.setBoolPref("network.ssl_tokens_cache_enabled", true); Services.prefs.setBoolPref("network.http.http3.enable", false); // ZeroRttAcceptServer binds IPv4 loopback only. HE otherwise tries // [::1] first and the losing IPv6 attempt perturbs 0-RTT state for // the winning IPv4 attempt, collapsing fetch#2 into the std path. // Race tasks supply both families via a reverse proxy and flip // this pref themselves. Services.prefs.setBoolPref("network.dns.disableIPv6", true); registerCleanupFunction(async () => { Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled"); Services.prefs.clearUserPref("network.http.speculative-parallel-limit"); Services.prefs.clearUserPref("network.ssl_tokens_cache_enabled"); Services.prefs.clearUserPref("network.http.http3.enable"); Services.prefs.clearUserPref("network.dns.disableIPv6"); if (callbackServer) { await callbackServer.stop(); } }); } ); function fetchExpect200(url) { return new Promise(resolve => { let chan = NetUtil.newChannel({ uri: url, loadUsingSystemPrincipal: true, }).QueryInterface(Ci.nsIHttpChannel); chan.loadFlags = Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI; chan.asyncOpen( new ChannelListener( req => { let httpChan = req.QueryInterface(Ci.nsIHttpChannel); let httpChanInt = req.QueryInterface(Ci.nsIHttpChannelInternal); let status = 0; let remote = ""; let resumed = false; try { status = httpChan.responseStatus; } catch (e) {} try { remote = httpChanInt.remoteAddress; } catch (e) {} try { resumed = req.securityInfo.resumed; } catch (e) {} resolve({ status, remote, resumed }); }, null, CL_ALLOW_UNKNOWN_CL ) ); }); } async function runHandshakeThenResume(host) { Services.prefs.setCharPref("network.dns.localDomains", host); const url = `https://${host}:8443/`; resetCounts(); let r1 = await fetchExpect200(url); Assert.equal(r1.status, 200, "First fetch should succeed"); // Anti-replay window + NewSessionTicket propagation. // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(resolve => setTimeout(resolve, 1500)); Services.obs.notifyObservers(null, "net:cancel-all-connections"); // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(resolve => setTimeout(resolve, 200)); let r2 = await fetchExpect200(url); Assert.equal(r2.status, 200, "Second fetch should succeed"); // Drop the keep-alive conn from fetch #2 so the server's single- // threaded accept loop (parked in PR_Recv inside HandleH1Session) // gets EOF and returns, freeing it for the next task's ClientHello. Services.obs.notifyObservers(null, "net:cancel-all-connections"); // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(resolve => setTimeout(resolve, 200)); } add_task( { skip_if: () => AppConstants.MOZ_SYSTEM_NSS, }, async function test_he_h1_0rtt_accepted_no_duplicate_on_the_wire() { // Server accepts 0-RTT. HE promotes the winning HT to forward to // the real txn. Real txn's request stream is positioned at the // winner's offset so ReadSegments returns 0 bytes and // nsHttpConnection treats the request as fully sent. No duplicate // request burst is produced on the wire. await runHandshakeThenResume("0rtt-accept-h1.example.com"); Assert.equal(stdCount, 1, "Fetch #1 arrived on the standard path"); Assert.equal(earlyCount, 1, "Fetch #2 arrived as accepted 0-RTT"); } ); add_task( { skip_if: () => AppConstants.MOZ_SYSTEM_NSS, }, async function test_he_h1_0rtt_rejected_restarts_cleanly() { // Server refuses 0-RTT on resumption (no anti-replay context), so // NSS reports "early data not accepted" and ZeroRttHandle drives // Finish0RTT(aRestart=true): the request stream seeks back to 0 // and the real txn retransmits over the post-handshake wire. The // server sees both fetches on the standard path (the early-data // bytes are discarded by NSS before our handler ever sees them). await runHandshakeThenResume("0rtt-reject-h1.example.com"); Assert.equal(earlyCount, 0, "No request should arrive as 0-RTT"); Assert.equal(stdCount, 2, "Both fetches arrived on the standard path"); } ); // Reverse TCP proxy that listens on ::1 and 127.0.0.1 at the same // port and forwards raw bytes to the TLS server on 127.0.0.1:8443. // Each family can be artificially delayed so HE picks a specific // winner in the 0-RTT resumption race. TLS is not terminated — the // ClientHello's SNI and early data pass through intact, so the // backend still decides ALPN / 0-RTT accept per our sHosts entries. async function startFamilyDelayProxy(node, ipv6DelayMs, ipv4DelayMs) { return node.execute(` (function() { const net = require("net"); function pipeToBackend(clientSocket, delay) { let buffered = []; let clientDead = false; clientSocket.on("data", (chunk) => buffered.push(chunk)); clientSocket.on("error", () => { clientDead = true; }); // Track client-side teardown up front: the HE race deliberately // cancels the losing attempt mid-delay, so without these early // listeners the deferred net.connect below would forward a // stale ClientHello to the single-threaded backend and park its // accept loop on a dead conn. clientSocket.on("end", () => { clientDead = true; }); clientSocket.on("close", () => { clientDead = true; }); setTimeout(() => { if (clientDead) { try { clientSocket.destroy(); } catch(e) {} return; } const backendSocket = net.connect(8443, "127.0.0.1", () => { for (const chunk of buffered) { backendSocket.write(chunk); } buffered = null; clientSocket.removeAllListeners("data"); clientSocket.on("data", (chunk) => backendSocket.write(chunk)); backendSocket.on("data", (chunk) => { try { clientSocket.write(chunk); } catch(e) {} }); backendSocket.on("end", () => { try { clientSocket.end(); } catch(e) {} }); clientSocket.on("end", () => backendSocket.end()); backendSocket.on("error", () => clientSocket.destroy()); clientSocket.on("error", () => backendSocket.destroy()); }); backendSocket.on("error", () => clientSocket.destroy()); }, delay); } function makeProxy(delay) { return net.createServer((clientSocket) => { pipeToBackend(clientSocket, delay); }); } const proxy6 = makeProxy(${ipv6DelayMs}); const proxy4 = makeProxy(${ipv4DelayMs}); return new Promise((resolve, reject) => { proxy6.once("error", reject); proxy6.listen(0, "::1", () => { const port = proxy6.address().port; proxy4.once("error", reject); proxy4.listen(port, "127.0.0.1", () => { global.delayProxy6 = proxy6; global.delayProxy4 = proxy4; resolve(port); }); }); }); })() `); } async function stopFamilyDelayProxy(node) { await node.execute(` if (global.delayProxy6) { global.delayProxy6.close(); global.delayProxy6 = null; } if (global.delayProxy4) { global.delayProxy4.close(); global.delayProxy4 = null; } `); } // Drives the accept-path H1 0-RTT test through a dual-family reverse // proxy. A native DNS override maps the hostname to both ::1 and // 127.0.0.1 at the proxy port; per-family connect delays pick which // address wins the HE race. Asserts fetch#1 comes through the std // path (full handshake) and fetch#2 comes through the early path // (0-RTT accepted on the winning family), matching the invariants of // the non-race accept task. async function runHe0RttRace(host, ipv6DelayMs, ipv4DelayMs) { let node = new NodeHTTPServer(); await node.start(); const override = Cc["@mozilla.org/network/native-dns-override;1"].getService( Ci.nsINativeDNSResolverOverride ); // Race needs both families live; non-race tasks set disableIPv6=true // globally. Restored in the finally block below. Services.prefs.clearUserPref("network.dns.disableIPv6"); Services.prefs.clearUserPref("network.dns.localDomains"); override.addIPOverride(host, "::1"); override.addIPOverride(host, "127.0.0.1"); // The earlier accept task cached a resumption ticket for this same // host. Without clearing it, this task's "first" fetch would attempt // 0-RTT instead of a full handshake, and the race between the two // HE attempts over the proxy trips the server's anti-replay guard. let nssComponent = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent); await nssComponent.asyncClearSSLExternalAndInternalSessionCache(); let proxyPort = await startFamilyDelayProxy(node, ipv6DelayMs, ipv4DelayMs); const url = `https://${host}:${proxyPort}/`; try { resetCounts(); let r1 = await fetchExpect200(url); Assert.equal(r1.status, 200, "First fetch should succeed"); Assert.equal(r1.resumed, false, "First fetch is a full handshake"); // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(resolve => setTimeout(resolve, 200)); Services.obs.notifyObservers(null, "net:cancel-all-connections"); // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(resolve => setTimeout(resolve, 100)); let r2 = await fetchExpect200(url); Assert.equal(r2.status, 200, "Second fetch should succeed"); Assert.equal(r2.resumed, true, "Second fetch should resume the session"); info(`fetch#1 remote=${r1.remote} resumed=${r1.resumed}`); info(`fetch#2 remote=${r2.remote} resumed=${r2.resumed}`); return { r1, r2 }; } finally { Services.obs.notifyObservers(null, "net:cancel-all-connections"); // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(resolve => setTimeout(resolve, 200)); await stopFamilyDelayProxy(node); await node.stop(); override.clearOverrides(); // Restore the non-race tasks' baseline. Services.prefs.setBoolPref("network.dns.disableIPv6", true); } } add_task( { skip_if: () => AppConstants.MOZ_SYSTEM_NSS || mozinfo.os == "android" || mozinfo.socketprocess_networking, }, async function test_he_h1_0rtt_ipv4_wins_race() { // Fetch#1 has no cached session, so no 0-RTT commit. IPv6 fires // first but its response is delayed 1s at the proxy; IPv4 backup // fires at 250ms and its full handshake completes first. No 0-RTT // sibling exists, so the non-0-RTT disqualify policy doesn't // engage — IPv4 wins the full handshake (r1.remote = 127.0.0.1). // // Fetch#2 has a cached ticket. HE again fires IPv6 first and // ZeroRttHandle commits the 0-RTT attempt to that (IPv6) HT. The // IPv4 backup fires without 0-RTT and is disqualified by // HappyEyeballsTransaction::Close once it finishes a plain // handshake. Firefox waits for IPv6 to complete on the wire, so // r2.resumed = true. (r2.remote ends up empty because the // channel's remoteAddress isn't populated when the connection is // promoted through HappyEyeballsTransaction.) let { r1 } = await runHe0RttRace("0rtt-accept-h1.example.com", 1000, 0); Assert.equal(r1.remote, "127.0.0.1", "fetch#1 should win on IPv4"); } ); add_task( { skip_if: () => AppConstants.MOZ_SYSTEM_NSS || mozinfo.os == "android" || mozinfo.socketprocess_networking, }, async function test_he_h1_0rtt_ipv6_wins_race() { // IPv6 fires first and is unimpeded; IPv4 backup is delayed and // never matters. Fetch#1 goes over IPv6; fetch#2 resumes via // 0-RTT on the same family. let { r1 } = await runHe0RttRace("0rtt-accept-h1.example.com", 0, 1000); Assert.equal(r1.remote, "::1", "fetch#1 wins on IPv6"); } );