Bug 2068800 - Stop presenting network resets on https as SSL errors r=dom-core-reviewers,smaug,valentin

NS_ERROR_NET_RESET and NS_ERROR_NET_INTERRUPT on an https URI are not evidence
of TLS version intolerance: the heuristic applies only during the handshake,
insecure fallback was disabled in bug 1479501, and genuine TLS failures reach
nssFailure2 by their own path.

Give netReset and NS_ERROR_NET_EMPTY_RESPONSE distinct error-page configs, and
test the empty-response case with an actual empty response.

Differential Revision: https://phabricator.services.mozilla.com/D323597
This commit is contained in:
Lars Eggert
2026-09-11 10:48:00 +00:00
committed by leggert@mozilla.com
parent e986c9e88a
commit a82f826396
9 changed files with 322 additions and 77 deletions
@@ -98,6 +98,8 @@ support-files = [
["browser_aboutNetError_netInterrupt.js"]
["browser_aboutNetError_netReset.js"]
["browser_aboutNetError_searchCTA.js"]
["browser_aboutNetError_searchCTA_connectivity.js"]
@@ -3,28 +3,34 @@
"use strict";
function startDropServer() {
const server = Cc["@mozilla.org/network/server-socket;1"].createInstance(
Ci.nsIServerSocket
);
info("Using a random port.");
server.init(-1, true, -1);
server.asyncListen({
onSocketAccepted(socket, transport) {
// Close immediately, no response sent.
transport.close(Cr.NS_OK);
},
onStopListening() {},
});
registerCleanupFunction(() => server.close());
return server.port;
const { NodeHTTPServer } = ChromeUtils.importESModule(
"resource://testing-common/NodeServer.sys.mjs"
);
// A 4xx response with no body is what nsURILoader turns into
// NS_ERROR_NET_EMPTY_RESPONSE.
//
// registerPathHandler ships the handler to a separate Node process via
// handler.toString(), so it must not close over anything from this scope;
// pick a self-contained handler before registering it.
async function startEmptyResponseServer(setContentLength) {
const server = new NodeHTTPServer();
await server.start();
registerCleanupFunction(() => server.stop());
const handler = setContentLength
? (req, resp) => {
resp.writeHead(404, { "Content-Length": "0" });
resp.end();
}
: (req, resp) => {
resp.writeHead(404);
resp.end();
};
await server.registerPathHandler("/empty", handler);
return `${server.origin()}/empty`;
}
add_task(async function test_net_empty_response_copy() {
await setSecurityCertErrorsFeltPrivacyToTrue();
const port = startDropServer();
const url = `http://127.0.0.1:${port}/`;
async function loadEmptyResponseErrorPage(url) {
let browser, tab;
let pageLoaded;
await BrowserTestUtils.openNewForegroundTab(
@@ -40,8 +46,10 @@ add_task(async function test_net_empty_response_copy() {
info("Loading and waiting for the net error.");
await pageLoaded;
return { browser, tab };
}
Assert.ok("Loaded empty server response.");
async function assertEmptyResponseCopy(browser) {
await SpecialPowers.spawn(browser, [], async () => {
await ContentTaskUtils.waitForCondition(
() => content?.document?.querySelector("net-error-card"),
@@ -60,27 +68,40 @@ add_task(async function test_net_empty_response_copy() {
);
Assert.equal(
netErrorCard.errorIntro.dataset.l10nId,
"neterror-http-empty-response-description",
"Using the 'empty response' intro."
"fp-neterror-http-error-intro",
"Using the HTTP error intro."
);
const list = netErrorCard.renderRoot.querySelector(".what-can-you-do-list");
Assert.ok(list, "NetErrorCard has what-can-you-do list.");
Assert.ok(
list.querySelector('[data-l10n-id="neterror-http-error-page"]'),
"List includes check-the-address item"
);
Assert.ok(
list.querySelector('[data-l10n-id="neterror-load-error-try-again"]'),
"List includes try-again item"
);
Assert.ok(
list.querySelector('[data-l10n-id="neterror-load-error-connection"]'),
"List includes connection item"
);
Assert.ok(
list.querySelector('[data-l10n-id="neterror-load-error-firewall"]'),
"List includes firewall item"
);
Assert.ok(
ContentTaskUtils.isVisible(netErrorCard.tryAgainButton),
"The 'Try Again' button is shown."
);
});
}
add_task(async function test_net_empty_response_copy() {
await setSecurityCertErrorsFeltPrivacyToTrue();
const url = await startEmptyResponseServer(true);
const { browser, tab } = await loadEmptyResponseErrorPage(url);
await assertEmptyResponseCopy(browser);
BrowserTestUtils.removeTab(tab);
});
add_task(async function test_net_empty_response_copy_no_content_length() {
await setSecurityCertErrorsFeltPrivacyToTrue();
const url = await startEmptyResponseServer(false);
const { browser, tab } = await loadEmptyResponseErrorPage(url);
await assertEmptyResponseCopy(browser);
BrowserTestUtils.removeTab(tab);
});
@@ -0,0 +1,195 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// Closing the connection right after accepting it, before any response is
// sent, surfaces as NS_ERROR_NET_RESET.
function startDropServer() {
const server = Cc["@mozilla.org/network/server-socket;1"].createInstance(
Ci.nsIServerSocket
);
server.init(-1, true, -1);
server.asyncListen({
onSocketAccepted(socket, transport) {
transport.close(Cr.NS_OK);
},
onStopListening() {},
});
registerCleanupFunction(() => server.close());
return server.port;
}
// nsITLSServerSocket needs a certificate with a corresponding private key
// available. In mochitests, the certificate with the common name "Mochitest
// client" has such a key.
async function getTestServerCertificate() {
const certDB = Cc["@mozilla.org/security/x509certdb;1"].getService(
Ci.nsIX509CertDB
);
for (const cert of await certDB.getCerts()) {
if (cert.commonName == "Mochitest client") {
return cert;
}
}
return null;
}
// Completes the TLS handshake, then closes the connection right after, also
// surfacing as NS_ERROR_NET_RESET (see browser_navigation_failures.js).
function startTLSDropServer(cert) {
const server = Cc["@mozilla.org/network/tls-server-socket;1"].createInstance(
Ci.nsITLSServerSocket
);
server.init(-1, true, -1);
server.serverCert = cert;
let input, output;
const listener = {
onSocketAccepted(socket, transport) {
const connectionInfo = transport.securityCallbacks.getInterface(
Ci.nsITLSServerConnectionInfo
);
connectionInfo.setSecurityObserver(listener);
input = transport.openInputStream(0, 0, 0);
output = transport.openOutputStream(0, 0, 0);
},
onHandshakeDone() {
input.asyncWait(
{
onInputStreamReady() {
input.close();
output.close();
},
},
0,
0,
Services.tm.currentThread
);
},
onStopListening() {},
};
server.setSessionTickets(false);
server.asyncListen(listener);
registerCleanupFunction(() => server.close());
return server;
}
async function loadDroppedConnectionErrorPage(url) {
let browser, tab;
let pageLoaded;
await BrowserTestUtils.openNewForegroundTab(
gBrowser,
() => {
gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url);
browser = gBrowser.selectedBrowser;
tab = gBrowser.selectedTab;
pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
},
false
);
await pageLoaded;
return { browser, tab };
}
add_task(async function test_netReset_from_dropped_connection_http() {
const port = startDropServer();
const { browser, tab } = await loadDroppedConnectionErrorPage(
`http://127.0.0.1:${port}/`
);
await SpecialPowers.spawn(browser, [], () => {
Assert.ok(
content.document.documentURI.includes("e=netReset"),
"A dropped http connection shows the netReset error page"
);
});
BrowserTestUtils.removeTab(tab);
});
add_task(async function test_netReset_from_dropped_connection_https() {
await SpecialPowers.pushPrefEnv({
// This test fails on some platforms if we leave IPv6 enabled.
set: [["network.dns.disableIPv6", true]],
});
const certOverrideService = Cc[
"@mozilla.org/security/certoverride;1"
].getService(Ci.nsICertOverrideService);
const cert = await getTestServerCertificate();
const server = startTLSDropServer(cert);
certOverrideService.rememberValidityOverride(
"localhost",
server.port,
{},
cert,
true
);
registerCleanupFunction(() => {
certOverrideService.clearValidityOverride("localhost", server.port, {});
});
const { browser, tab } = await loadDroppedConnectionErrorPage(
`https://localhost:${server.port}/`
);
await SpecialPowers.spawn(browser, [], () => {
Assert.ok(
content.document.documentURI.includes("e=netReset"),
"A dropped https connection shows the netReset error page"
);
});
BrowserTestUtils.removeTab(tab);
});
add_task(async function test_netReset_error_page_elements() {
const { browser, tab } = await loadNetErrorPage("netReset", "127.0.0.1");
await SpecialPowers.spawn(browser, [], async function () {
await ContentTaskUtils.waitForCondition(
() => content?.document?.querySelector("net-error-card"),
"Wait for net-error-card to render"
);
const doc = content.document;
const netErrorCard = doc.querySelector("net-error-card").wrappedJSObject;
await netErrorCard.getUpdateComplete();
Assert.equal(
netErrorCard.errorTitle.dataset.l10nId,
"netReset-title",
"Using the netReset title"
);
Assert.equal(
netErrorCard.errorIntro.dataset.l10nId,
"fp-neterror-offline-intro",
"Using the netReset intro"
);
const list = netErrorCard.renderRoot.querySelector(".what-can-you-do-list");
Assert.ok(list, "NetErrorCard has what-can-you-do list");
Assert.ok(
list.querySelector('[data-l10n-id="neterror-load-error-try-again"]'),
"List includes try-again item"
);
Assert.ok(
list.querySelector('[data-l10n-id="neterror-load-error-connection"]'),
"List includes connection item"
);
Assert.ok(
list.querySelector('[data-l10n-id="neterror-load-error-firewall"]'),
"List includes firewall item"
);
Assert.ok(
ContentTaskUtils.isVisible(netErrorCard.tryAgainButton),
"The 'Try Again' button is shown"
);
Assert.ok(
!netErrorCard.renderRoot.querySelector(
'[data-l10n-id="fp-cert-error-code"]'
),
"No error code is shown for netReset"
);
});
BrowserTestUtils.removeTab(tab);
});
@@ -155,10 +155,6 @@ add_task(async function () {
);
let identityMode = window.document.getElementById("identity-box").className;
is(
identityMode,
"certErrorPage notSecureText",
"identity should be 'unknown'"
);
is(identityMode, "unknownIdentity", "identity should be 'unknown'");
});
});
-6
View File
@@ -3756,12 +3756,6 @@ nsDocShell::DisplayLoadError(nsresult aError, nsIURI* aURI,
// Display the error as a page or an alert prompt
NS_ENSURE_FALSE(messageStr.IsEmpty(), NS_ERROR_FAILURE);
if ((NS_ERROR_NET_INTERRUPT == aError || NS_ERROR_NET_RESET == aError) &&
aURI->SchemeIs("https")) {
// Maybe TLS intolerant. Treat this as an SSL error.
error = "nssFailure2";
}
if (mBrowsingContext->GetUseErrorPages()) {
// Display an error page
nsresult loadedPage =
+2
View File
@@ -323,6 +323,8 @@ support-files = [
["browser_dataURI_unique_opaque_origin.js"]
https_first_disabled = true
["browser_displayLoadError_netReset.js"]
["browser_fall_back_to_https.js"]
https_first_disabled = true
skip-if = [
@@ -0,0 +1,39 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// Bug 2068800: a connection reset or interruption on an https URI used to be
// relabelled as an SSL error, on the assumption that it meant TLS version
// intolerance. Check that these errors reach their own error pages instead.
async function errorPageFor(browser, errorName, uri) {
const loaded = BrowserTestUtils.browserLoaded(browser, false, uri, true);
await SpecialPowers.spawn(browser, [errorName, uri], (name, spec) => {
docShell.displayLoadError(Cr[name], Services.io.newURI(spec), null, null);
});
const internalURL = await loaded;
return new URLSearchParams(internalURL.split("?")[1]).get("e");
}
add_task(async function test_net_errors_are_not_ssl_errors() {
await BrowserTestUtils.withNewTab("about:blank", async browser => {
for (const [errorName, expected] of [
["NS_ERROR_NET_RESET", "netReset"],
["NS_ERROR_NET_INTERRUPT", "netInterrupt"],
]) {
for (const scheme of ["https", "http"]) {
const shown = await errorPageFor(
browser,
errorName,
`${scheme}://example.com/`
);
Assert.equal(
shown,
expected,
`${errorName} on ${scheme} shows the ${expected} page`
);
}
}
});
});
+31 -32
View File
@@ -175,38 +175,6 @@ export const NET_ERRORS = [
},
hasNoUserFix: false,
},
{
id: "netReset",
errorCode: "NS_ERROR_NET_EMPTY_RESPONSE",
category: "net",
bodyTitleL10nId: "problem-with-this-site-title",
introContent: {
dataL10nId: "neterror-http-empty-response-description",
dataL10nArgs: { hostname: null },
},
descriptionParts: DESCRIPTION_PARTS_MAP.connectionFailureDescription,
buttons: {
showTryAgain: true,
showGoBack: false,
},
customNetError: {
titleL10nId: "problem-with-this-site-title",
whatCanYouDoItems(context) {
const items = [
"neterror-load-error-try-again",
"neterror-load-error-connection",
"neterror-load-error-firewall",
];
if (context.showOSXPermissionWarning) {
items.push("neterror-load-osx-permission");
}
return items;
},
showErrorCode: true,
},
hasNoUserFix: false,
image: NET_ERROR_ILLUSTRATIONS.noConnection,
},
{
id: "nssBadCert",
errorCode: "nssBadCert",
@@ -305,6 +273,37 @@ export const NET_ERRORS = [
hasNoUserFix: false,
image: NET_ERROR_ILLUSTRATIONS.noConnection,
},
{
id: "netReset",
errorCode: "netReset",
category: "net",
bodyTitleL10nId: "netReset-title",
introContent: {
dataL10nId: "fp-neterror-offline-intro",
dataL10nArgs: { hostname: null },
},
descriptionParts: DESCRIPTION_PARTS_MAP.connectionFailureDescription,
buttons: {
showTryAgain: true,
showGoBack: false,
},
customNetError: {
titleL10nId: "netReset-title",
whatCanYouDoItems(context) {
const items = [
"neterror-load-error-try-again",
"neterror-load-error-connection",
"neterror-load-error-firewall",
];
if (context.showOSXPermissionWarning) {
items.push("neterror-load-osx-permission");
}
return items;
},
},
hasNoUserFix: false,
image: NET_ERROR_ILLUSTRATIONS.noConnection,
},
{
id: "netTimeout",
errorCode: "netTimeout",
@@ -99,9 +99,6 @@ neterror-load-osx-permission = If you are trying to load a local network page, p
neterror-http-error-page = Check to make sure youve typed the website address correctly.
neterror-http-empty-response = Check to make sure youve typed the website address correctly and try again in a few moments.
# Variables:
# $hostname (String) - Hostname of the website to which the user was trying to connect.
neterror-http-empty-response-description = { $hostname } sent back an empty page.
neterror-captive-portal = You must log in to this network before you can access the internet.