/* 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/. */ // Regression test: TCP 0-RTT with alpnChanged=1 (H2 ticket, server responds H1) // must not declare the dying connection the HE winner and insert it into // mActiveConns. With mUsingSpdy=true on the CM entry, RestrictConnections() // would permanently block all new connection attempts for the host. // // Setup: warm-up over IPv4-only establishes mUsingSpdy=true and leaves an H2 // session ticket; the race (IPv4 0 ms, IPv6 100 ms) fires 0-RTT with that // ticket while the server now negotiates H1, triggering alpnChanged=1. "use strict"; const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { HttpServer } = ChromeUtils.importESModule( "resource://testing-common/httpd.sys.mjs" ); const { NodeHTTPServer } = ChromeUtils.importESModule( "resource://testing-common/NodeServer.sys.mjs" ); var { setTimeout } = ChromeUtils.importESModule( "resource://gre/modules/Timer.sys.mjs" ); const override = Cc["@mozilla.org/network/native-dns-override;1"].getService( Ci.nsINativeDNSResolverOverride ); let callbackServer; let gServerStarted = false; add_setup( { skip_if: () => AppConstants.MOZ_SYSTEM_NSS, }, async () => { callbackServer = new HttpServer(); callbackServer.registerPrefixHandler("/callback/", () => {}); callbackServer.start(-1); Services.env.set( "MOZ_ZERORTT_ACCEPT_CALLBACK_PORT", callbackServer.identity.primaryPort ); Services.env.set("MOZ_TLS_SERVER_0RTT", "1"); const started = await asyncStartTLSTestServer( "ZeroRttAcceptServer", "../../../security/manager/ssl/tests/unit/test_faulty_server" ); if (!started) { return; } gServerStarted = true; let nss = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent); await nss.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); // Make the H2 idle timeout very short so the warm-up H2 session is // reclaimed quickly, leaving the CM entry alive (mUsingSpdy=true) but // mActiveConns empty before the race starts. Services.prefs.setIntPref("network.http.http2.timeout", 2); 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.http.http2.timeout"); Services.prefs.clearUserPref("network.dns.disableIPv6"); override.clearOverrides(); if (callbackServer) { await callbackServer.stop(); callbackServer = null; } }); } ); // Mutable proxy: constant port (same ConnectionInfo key throughout) with // configurable per-family delay. async function startMutableProxy(node) { return node.execute(` (function() { const net = require("net"); global.__proxy = { ipv6Ms: 0, ipv4Ms: 0, p6: null, p4: null }; function fwd(client, family) { const ms = global.__proxy[family + "Ms"]; let buf = [], dead = false; client.on("data", c => buf.push(c)); ["error","end","close"].forEach(e => client.on(e, () => { dead = true; })); setTimeout(() => { if (dead) { try { client.destroy(); } catch(_) {} return; } const b = net.connect(8443, "127.0.0.1", () => { buf.forEach(c => b.write(c)); buf = null; client.removeAllListeners("data"); client.on("data", c => b.write(c)); b.on("data", c => { try { client.write(c); } catch(_) {} }); b.on("end", () => { try { client.end(); } catch(_) {} }); client.on("end", () => b.end()); b.on("error", () => client.destroy()); client.on("error", () => b.destroy()); }); b.on("error", () => client.destroy()); }, ms); } const p6 = net.createServer(s => fwd(s, "ipv6")); const p4 = net.createServer(s => fwd(s, "ipv4")); global.__proxy.p6 = p6; global.__proxy.p4 = p4; return new Promise((res, rej) => { p6.once("error", rej); p6.listen(0, "::1", () => { const port = p6.address().port; p4.once("error", rej); p4.listen(port, "127.0.0.1", () => res(port)); }); }); })() `); } async function setProxyDelay(node, ipv6Ms, ipv4Ms) { await node.execute( `global.__proxy.ipv6Ms=${ipv6Ms}; global.__proxy.ipv4Ms=${ipv4Ms};` ); } async function stopProxy(node) { await node.execute(` if (global.__proxy) { global.__proxy.p6.close(); global.__proxy.p4.close(); global.__proxy = null; } `); } function fetchNoThrow(url) { const chan = NetUtil.newChannel({ uri: url, loadUsingSystemPrincipal: true, }).QueryInterface(Ci.nsIHttpChannel); chan.loadFlags = Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI | Ci.nsIRequest.LOAD_BYPASS_CACHE; const promise = new Promise(resolve => { chan.asyncOpen({ onStartRequest() {}, onDataAvailable(_req, stream, _offset, count) { read_stream(stream, count); }, onStopRequest(req, status) { if (Components.isSuccessCode(status)) { resolve({ ok: true, status: req.QueryInterface(Ci.nsIHttpChannel).responseStatus, }); } else { resolve({ ok: false, status: 0, error: status }); } }, QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), }); }); return { chan, promise }; } add_task( { skip_if: () => AppConstants.MOZ_SYSTEM_NSS || !gServerStarted || mozinfo.os == "android" || mozinfo.socketprocess_networking, }, async function test_he_0rtt_alpn_switch_dead_winner_blocks_host() { // Warm-up (IPv4-only) sets mUsingSpdy=true on the CM entry and issues an // H2 session ticket. After the idle timeout, the CM entry stays alive // (mUsingSpdy=true, mActiveConns empty). The race fires 0-RTT with that // ticket; the server responds H1 → alpnChanged=1 → Finish0RTT fires. const host = "0rtt-alpn-switch.example.com"; const node = new NodeHTTPServer(); await node.start(); try { let nss = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent); await nss.asyncClearSSLExternalAndInternalSessionCache(); override.clearOverrides(); override.addIPOverride(host, "::1"); override.addIPOverride(host, "127.0.0.1"); // One proxy, constant port, so warm-up and race share the same // ConnectionInfo key (and therefore the same CM entry / mUsingSpdy flag). const port = await startMutableProxy(node); const url = `https://${host}:${port}/`; // ── Warm-up (IPv4 only, 0 ms delay) ────────────────────────────────── // Server handles connection #0 as H2, issues a session ticket. // ReportSpdyConnection sets mUsingSpdy=true on the CM entry. Services.prefs.setBoolPref("network.dns.disableIPv6", true); const wu = fetchNoThrow(url); const wuResult = await wu.promise; Assert.ok(wuResult.ok, "warm-up must succeed"); Services.prefs.setBoolPref("network.dns.disableIPv6", false); // Let the H2 idle timeout (2 s) fire so the warm-up H2 session is // reclaimed and mActiveConns becomes empty. The CM entry stays alive // (mUsingSpdy=true) — DO NOT call net:cancel-all-connections, which // would remove the entry from mCT and lose mUsingSpdy=true. // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(r => setTimeout(r, 3000)); // ── Race (IPv6 100 ms, IPv4 0 ms) ──────────────────────────────────── // Server uses H1 for all subsequent connections (gAlpnSwitchCount >= 1). // Client's H2 ticket → 0-RTT; server responds H1 → alpnChanged=1 → // Finish0RTT(restart=1) fires. // // Without the fix: HE declares the dying connection the winner, inserting // it into mActiveConns. npnPending=true (only Start0RTTSpdy ran) → // RestrictConnections() permanently blocks the re-queued transaction. // // Only one request: with multiple requests, other HCAs (no ticket, plain // H1) would win and remove the dead winner via MakeAllDontReuseExcept, // self-healing the deadlock before the timeout fires. await setProxyDelay(node, 100, 0); const TIMEOUT_MS = 10000; let timedOut = false; const f1 = fetchNoThrow(url); const result = await Promise.race([ f1.promise, new Promise(r => // eslint-disable-next-line mozilla/no-arbitrary-setTimeout setTimeout(() => { timedOut = true; r(null); }, TIMEOUT_MS) ), ]); if (timedOut) { try { f1.chan.cancel(Cr.NS_ERROR_ABORT); } catch (_) {} await f1.promise; } Assert.ok( !timedOut, "Request must complete — without the fix, Finish0RTT(alpnChanged=1) " + "inserts the dead connection into mActiveConns and " + "RestrictConnections() permanently blocks new connections for this host." ); if (!timedOut) { Assert.ok(result.ok, "request should return HTTP 200"); } } finally { Services.obs.notifyObservers(null, "net:cancel-all-connections"); // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(r => setTimeout(r, 200)); await stopProxy(node); await node.stop(); override.clearOverrides(); } } );