Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

/* 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/. */
"use strict";
// Verifies the Happy Eyeballs client-auth pause: when a TLS server requests
// a client cert from an HE racer, HE stops polling the state machine until
// the prompt is resolved, so no new connection attempts are started while the
// cert-selection dialog is up. Already-running attempts are not cancelled and
// may show their own prompts (allowed), so the dialog can fire more than once.
//
// Setup: Node HTTPS server with requestCert=true. Node listens on the IPv6
// wildcard `::` which on Linux/macOS accepts both IPv4 (via IPv4-mapped
// addresses) and IPv6 connections. The test channel targets `localhost`,
// which on /etc/hosts resolves to BOTH 127.0.0.1 and ::1 — that's what
// gives HE two address families to race. The dialog mock delays its
// response to keep the holding racer parked while the
// connection-attempt-delay timer fires.
//
// Assertion: the request succeeds after a cert is selected and the dialog is
// invoked at least once for the requested hostname. We don't assert an exact
// count: whether the second racer started before the pause is timing
// dependent.
/* import-globals-from head_channels.js */
const { MockRegistrar } = ChromeUtils.importESModule(
);
const { NodeHTTPSServer } = ChromeUtils.importESModule(
);
const { setTimeout } = ChromeUtils.importESModule(
"resource://gre/modules/Timer.sys.mjs"
);
const ctypes = ChromeUtils.importESModule(
"resource://gre/modules/ctypes.sys.mjs"
).ctypes;
// Minimal inlined copy of head_psm.js's loadPKCS11Module — the full file
// can't be loaded here because it conflicts with netwerk's default
// head_channels.js (both declare XPCOMUtils).
async function loadPKCS11Module(libraryFile, moduleName) {
Assert.ok(libraryFile.exists(), "PKCS11 module file should exist");
let pkcs11ModuleDB = Cc["@mozilla.org/security/pkcs11moduledb;1"].getService(
Ci.nsIPKCS11ModuleDB
);
registerCleanupFunction(async () => {
try {
await pkcs11ModuleDB.deleteModule(moduleName);
} catch (e) {
/* ignore */
}
});
await pkcs11ModuleDB.addModule(moduleName, libraryFile.path, 0, 0);
}
const TEST_BROWSER_ID = 4242;
// Long enough to comfortably exceed HE's connection-attempt-delay (set
// below) so the second racer would fire well before our dialog responds.
const DIALOG_RESPONSE_DELAY_MS = 1500;
const HE_CONNECTION_ATTEMPT_DELAY_MS = 50;
let gServer;
let gChooseCertificateInvocations = 0;
let gChooseCertificateHostnames = [];
const gClientAuthDialogService = {
QueryInterface: ChromeUtils.generateQI(["nsIClientAuthDialogService"]),
chooseCertificate(hostname, certArray, _loadContext, _caNames, callback) {
gChooseCertificateInvocations++;
gChooseCertificateHostnames.push(hostname);
// The delay keeps the holder's TLS handshake parked while HE's
// connection-attempt-delay timer fires. With the pause working, polling
// is stopped so no new attempt is started off that timer.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
setTimeout(() => {
if (certArray && certArray.length) {
callback.certificateChosen(certArray[0], false);
} else {
callback.certificateChosen(null, false);
}
}, DIALOG_RESPONSE_DELAY_MS);
},
};
MockRegistrar.register(
"@mozilla.org/security/ClientAuthDialogService;1",
gClientAuthDialogService
);
function makeChan(uri, browserId) {
let chan = NetUtil.newChannel({
uri,
loadUsingSystemPrincipal: true,
}).QueryInterface(Ci.nsIHttpChannel);
chan.loadFlags = Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI;
chan.browserId = browserId;
return chan;
}
function channelOpenPromise(chan, flags) {
return new Promise(resolve => {
chan.asyncOpen(
new ChannelListener((req, buffer) => resolve([req, buffer]), null, flags)
);
});
}
add_setup(async function setup() {
do_get_profile();
Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", true);
// Shrink HE's connection-attempt-delay so the second-family racer fires
// promptly while the first racer is still blocked on the cert dialog —
// makes the race window observable in CI.
Services.prefs.setIntPref(
"network.http.happy_eyeballs_connection_attempt_delay",
HE_CONNECTION_ATTEMPT_DELAY_MS
);
Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0);
// Keep client-cert discovery confined to the test pkcs11 module. Otherwise
// on macOS the osclientcerts module queries the system keychain, which pops
// a native OS authorization/cert-selection dialog that xpcshell can't
// dismiss (the test would hang until timeout). This is separate from the
// mocked nsIClientAuthDialogService.
Services.prefs.setBoolPref("security.osclientcerts.autoload", false);
// Load the pkcs11 test module so the cert candidate list isn't empty.
// PSM bypasses the dialog entirely when no candidates exist. The dylib is
// built and installed only in PSM's test working directory; reach across
// _tests/xpcshell/ to find it.
let libraryFile = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
// CurWorkD = _tests/xpcshell/netwerk/test/unit → up to _tests/xpcshell.
libraryFile = libraryFile.parent.parent.parent;
libraryFile.append("security");
libraryFile.append("manager");
libraryFile.append("ssl");
libraryFile.append("tests");
libraryFile.append("unit");
libraryFile.append("pkcs11testmodule");
libraryFile.append(ctypes.libraryName("pkcs11testmodule"));
await loadPKCS11Module(libraryFile, "PKCS11 Test Module");
Cc["@mozilla.org/security/x509certdb;1"]
.getService(Ci.nsIX509CertDB)
.getCerts();
gServer = new NodeHTTPSServer();
gServer.setRequestClientCert(true);
await gServer.start();
await gServer.registerPathHandler("/", (_req, resp) => {
resp.writeHead(200, { "Content-Type": "text/plain", "Content-Length": 2 });
resp.end("OK");
});
registerCleanupFunction(async () => {
Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled");
Services.prefs.clearUserPref(
"network.http.happy_eyeballs_connection_attempt_delay"
);
Services.prefs.clearUserPref("network.http.speculative-parallel-limit");
Services.prefs.clearUserPref("security.osclientcerts.autoload");
await gServer.stop();
});
});
add_task(async function test_he_pauses_other_racers_on_client_auth() {
gChooseCertificateInvocations = 0;
gChooseCertificateHostnames.length = 0;
// Verify the host actually has two address families locally — otherwise
// there's nothing for HE to race and the test would trivially pass.
let resolved = await new Promise((resolve, reject) => {
Services.dns.asyncResolve(
"localhost",
Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
Ci.nsIDNSService.RESOLVE_DEFAULT_FLAGS,
null,
{
QueryInterface: ChromeUtils.generateQI(["nsIDNSListener"]),
onLookupComplete(_req, rec, status) {
if (!Components.isSuccessCode(status)) {
reject(status);
return;
}
let addrs = [];
rec = rec.QueryInterface(Ci.nsIDNSAddrRecord);
while (rec.hasMore()) {
addrs.push(rec.getNextAddrAsString());
}
resolve(addrs);
},
},
Services.tm.currentThread,
{}
);
});
info(`localhost resolved to: ${resolved.join(", ")}`);
Assert.greaterOrEqual(
resolved.length,
2,
"localhost must have at least two addresses (likely 127.0.0.1 and ::1) " +
"for HE to have something to race"
);
let chan = makeChan(`${gServer.origin()}/`, TEST_BROWSER_ID);
let [req] = await channelOpenPromise(chan, CL_ALLOW_UNKNOWN_CL);
Assert.equal(
req.QueryInterface(Ci.nsIHttpChannel).responseStatus,
200,
"request should succeed after client cert is selected"
);
Assert.greaterOrEqual(
gChooseCertificateInvocations,
1,
"chooseCertificate should fire at least once " +
`(got ${gChooseCertificateInvocations}: ` +
`${gChooseCertificateHostnames.join(", ")})`
);
Assert.equal(
gChooseCertificateHostnames[0],
"localhost",
"dialog should be invoked for the requested hostname"
);
});