From a82f8263961ed01627d406cc676665309471b24a Mon Sep 17 00:00:00 2001 From: Lars Eggert Date: Fri, 11 Sep 2026 10:45:40 +0000 Subject: [PATCH] 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 --- browser/base/content/test/about/browser.toml | 2 + .../browser_aboutNetError_emptyResponse.js | 83 +++++--- .../about/browser_aboutNetError_netReset.js | 195 ++++++++++++++++++ .../browser_navigation_failures.js | 6 +- docshell/base/nsDocShell.cpp | 6 - docshell/test/browser/browser.toml | 2 + .../browser_displayLoadError_netReset.js | 39 ++++ toolkit/content/errors/net-errors.mjs | 63 +++--- .../en-US/toolkit/neterror/netError.ftl | 3 - 9 files changed, 322 insertions(+), 77 deletions(-) create mode 100644 browser/base/content/test/about/browser_aboutNetError_netReset.js create mode 100644 docshell/test/browser/browser_displayLoadError_netReset.js diff --git a/browser/base/content/test/about/browser.toml b/browser/base/content/test/about/browser.toml index 97728d696f4a..882d9cb4ac95 100644 --- a/browser/base/content/test/about/browser.toml +++ b/browser/base/content/test/about/browser.toml @@ -98,6 +98,8 @@ support-files = [ ["browser_aboutNetError_netInterrupt.js"] +["browser_aboutNetError_netReset.js"] + ["browser_aboutNetError_searchCTA.js"] ["browser_aboutNetError_searchCTA_connectivity.js"] diff --git a/browser/base/content/test/about/browser_aboutNetError_emptyResponse.js b/browser/base/content/test/about/browser_aboutNetError_emptyResponse.js index 6a9cba9d8da0..6f4b3f0b699a 100644 --- a/browser/base/content/test/about/browser_aboutNetError_emptyResponse.js +++ b/browser/base/content/test/about/browser_aboutNetError_emptyResponse.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); }); diff --git a/browser/base/content/test/about/browser_aboutNetError_netReset.js b/browser/base/content/test/about/browser_aboutNetError_netReset.js new file mode 100644 index 000000000000..ab354eda8c8a --- /dev/null +++ b/browser/base/content/test/about/browser_aboutNetError_netReset.js @@ -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); +}); diff --git a/browser/base/content/test/siteIdentity/browser_navigation_failures.js b/browser/base/content/test/siteIdentity/browser_navigation_failures.js index c7bb9953501d..06b2901ea6d5 100644 --- a/browser/base/content/test/siteIdentity/browser_navigation_failures.js +++ b/browser/base/content/test/siteIdentity/browser_navigation_failures.js @@ -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'"); }); }); diff --git a/docshell/base/nsDocShell.cpp b/docshell/base/nsDocShell.cpp index 48be2ce822c1..1cb52cd0bace 100644 --- a/docshell/base/nsDocShell.cpp +++ b/docshell/base/nsDocShell.cpp @@ -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 = diff --git a/docshell/test/browser/browser.toml b/docshell/test/browser/browser.toml index 61e69f600b41..65383a5dd478 100644 --- a/docshell/test/browser/browser.toml +++ b/docshell/test/browser/browser.toml @@ -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 = [ diff --git a/docshell/test/browser/browser_displayLoadError_netReset.js b/docshell/test/browser/browser_displayLoadError_netReset.js new file mode 100644 index 000000000000..840107ac107d --- /dev/null +++ b/docshell/test/browser/browser_displayLoadError_netReset.js @@ -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` + ); + } + } + }); +}); diff --git a/toolkit/content/errors/net-errors.mjs b/toolkit/content/errors/net-errors.mjs index 22949dcec542..2e82dc891eea 100644 --- a/toolkit/content/errors/net-errors.mjs +++ b/toolkit/content/errors/net-errors.mjs @@ -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", diff --git a/toolkit/locales/en-US/toolkit/neterror/netError.ftl b/toolkit/locales/en-US/toolkit/neterror/netError.ftl index 4a2e4127c992..6159e363d1c7 100644 --- a/toolkit/locales/en-US/toolkit/neterror/netError.ftl +++ b/toolkit/locales/en-US/toolkit/neterror/netError.ftl @@ -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 you’ve typed the website address correctly. neterror-http-empty-response = Check to make sure you’ve 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.