CVE-2025-6434
Overview
Background
- HTTPS-Only mode
- When a navigation to an
http://URL cannot be upgraded, Firefox shows the HTTPS-Only error page (toolkit/components/httpsonlyerror/content/errorpage.html) with a “Continue to HTTP Site” button (id="openInsecure") that, when clicked, callsdocument.reloadWithHttpsOnlyException()and loads the insecure page. - Anti-clickjacking security delay
security.dialog_enable_delay(default 1000 ms) is the interval after a security UI appears/focuses during which a confirming control must stay inert, so a user cannot be tricked into clicking a button that is placed under their cursor just before it becomes active.- `inert` attribute
- An HTML attribute that fully disables an element: it cannot be focused or receive events. The old implementation disabled the button with
inertand removed it once after a fixed timeout. - Clickjacking
- An attacker frames/overlays or times UI so the victim’s click lands on a sensitive control (CWE-1021, UI redressing) without the victim realizing it.
Root Cause Analysis
The security delay was armed exactly once, in initPage() of errorpage.js: a single setTimeout removed the inert attribute after security.dialog_enable_delay. Because inert makes the element incapable of receiving JS events, the page could not observe focus changes on it, and nothing re-armed the delay if the page lost and regained focus (or was reloaded into focus via the identity pane). An attacker could therefore load the error page, let the one-shot timer expire while the tab is backgrounded or obscured, then surface the page and induce a click, so the “Continue to HTTP Site” button was already active with no fresh delay protecting it.
The patch replaces the one-shot inert scheme with a focus-driven, continuously re-armed delay. The button now carries a class="disabled" (styled via the new button.disabled rule in aboutHttpsOnlyError.css) that only looks disabled but still receives events. onFocus/onBlur handlers and resetClickjackingTimeout()/clearClickjackingTimeout() re-start the timer on every focus gain and disable the button on every blur, so the full delay must elapse after the most recent focus before the button becomes clickable. onOpenInsecureButtonClick now checks whether the target still has the disabled class; if so it stopPropagation()/preventDefault() and resets the timeout instead of proceeding. Crucially, browser-siteIdentity.js adds gBrowser.selectedBrowser.focus() when the HTTPS-Only exception is removed via the identity pane, so a reload that lands on the error page correctly triggers a fresh blur/focus cycle and re-arms the delay.
Attack Path
- Setup
Attacker causes the victim to open an
http://URL that fails to upgrade, rendering the HTTPS-Only error page (inferred: attacker controls or links to the page). - Timer burn-down
The page is loaded while backgrounded/obscured or otherwise not the user’s focus; the single
setTimeoutelapses andinertis removed, leaving the button active. - UI redressing Attacker brings the button into view / under the cursor (e.g. a tab-switch, overlay, or enticing click target) with no new delay armed.
- Victim click
The victim clicks what is now the already-active “Continue to HTTP Site” button, invoking
document.reloadWithHttpsOnlyException(). - Primitive An HTTPS-Only exception is granted and the page loads over plaintext HTTP, downgrading the connection without a protected, deliberate user confirmation.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifbrowser/base/content/browser-siteIdentity.js |
modified | |
iftoolkit/components/httpsonlyerror/content/errorpage.js |
modified | |
ifdom/security/test/https-first/browser_https_telemetry.js |
modified | |
fordom/security/test/https-only/browser_continue_button_delay.js |
modified | |
add_taskdom/security/test/https-only/browser_continue_button_delay.js |
modified | |
iftoolkit/components/httpsonlyerror/tests/browser/head.js |
modified |
Files Changed
browser/base/content/browser-siteIdentity.jsdom/security/test/https-first/browser_https_telemetry.jsdom/security/test/https-only/browser_continue_button_delay.jstoolkit/components/httpsonlyerror/content/errorpage.htmltoolkit/components/httpsonlyerror/content/errorpage.jstoolkit/components/httpsonlyerror/tests/browser/head.jstoolkit/themes/shared/aboutHttpsOnlyError.css
Audit Directions
- Other click-through security dialogsAudit any other in-content warning/confirm UI (add-on install, permission prompts, other error pages) for one-shot vs. focus-re-armed delays; the correct pattern is re-arm on every focus.
- `inert`-based gatingLook for other security controls gated by
inertthat therefore cannot observe focus events and cannot re-arm a delay. - Focus restoration pathsReview code paths that reload content into an error page (identity pane, reload-skip-cache) to ensure focus is set so the delay re-arms, as was needed in
browser-siteIdentity.js.
Patch
diff --git a/browser/base/content/browser-siteIdentity.js b/browser/base/content/browser-siteIdentity.js
index 71d085cc45c..def0375826a 100644
--- a/browser/base/content/browser-siteIdentity.js
+++ b/browser/base/content/browser-siteIdentity.js
@@ -617,6 +617,9 @@ var gIdentityHandler = {
if (this._popupInitialized) {
PanelMultiView.hidePopup(this._identityPopup);
}
+ // Ensure the browser is focused again, otherwise we may not trigger the
+ // security delay on a potential error page following this reload.
+ gBrowser.selectedBrowser.focus();
return;
}
// Otherwise we just refresh the interface
diff --git a/toolkit/components/httpsonlyerror/content/errorpage.html b/toolkit/components/httpsonlyerror/content/errorpage.html
index 89e5e3cda37..1cfd46594a0 100644
--- a/toolkit/components/httpsonlyerror/content/errorpage.html
+++ b/toolkit/components/httpsonlyerror/content/errorpage.html
@@ -64,10 +64,12 @@
class="primary"
data-l10n-id="about-httpsonly-button-go-back"
></button>
+ <!-- This button only looks "disabled", but still allows JS events to
+ propagate for the clickjacking protection. -->
<button
id="openInsecure"
data-l10n-id="about-httpsonly-button-continue-to-site"
- inert
+ class="disabled"
></button>
</div>
diff --git a/toolkit/components/httpsonlyerror/content/errorpage.js b/toolkit/components/httpsonlyerror/content/errorpage.js
index 6b5fc931f48..7c7d5fd5dbd 100644
--- a/toolkit/components/httpsonlyerror/content/errorpage.js
+++ b/toolkit/components/httpsonlyerror/content/errorpage.js
@@ -7,6 +7,8 @@
"use strict";
const searchParams = new URLSearchParams(document.documentURI.split("?")[1]);
+const clickjackingDelay = RPMGetIntPref("security.dialog_enable_delay", 1000);
+let clickjackingTimeout;
function initPage() {
if (!searchParams.get("e")) {
@@ -49,10 +51,8 @@ function initPage() {
.getElementById("goBack")
.addEventListener("click", onReturnButtonClick);
- const delay = RPMGetIntPref("security.dialog_enable_delay", 1000);
- setTimeout(() => {
- document.getElementById("openInsecure").removeAttribute("inert");
- }, delay);
+ document.addEventListener("blur", onBlur);
+ document.addEventListener("focus", onFocus);
addAutofocus("#goBack", "beforeend");
@@ -111,14 +111,33 @@ function openSecureWWWButtonClick() {
RPMOpenSecureWWWLink();
}
-function onOpenInsecureButtonClick() {
- document.reloadWithHttpsOnlyException();
+function onOpenInsecureButtonClick(e) {
+ if (e.target.classList.contains("disabled")) {
+ e.stopPropagation();
+ e.preventDefault();
+ resetClickjackingTimeout();
+ } else {
+ document.reloadWithHttpsOnlyException();
+ }
}
function onReturnButtonClick() {
RPMSendAsyncMessage("goBack");
}
+/* Focus Events */
+
+function onFocus() {
+ resetClickjackingTimeout();
+}
+
+function onBlur() {
+ clearClickjackingTimeout();
+ if (!document.getElementById("openInsecure").classList.contains("disabled")) {
+ document.getElementById("openInsecure").classList.add("disabled");
+ }
+}
+
/* Utils */
function addAutofocus(selector, position = "afterbegin") {
@@ -132,6 +151,23 @@ function addAutofocus(selector, position = "afterbegin") {
parent.insertAdjacentElement(position, button);
}
+function resetClickjackingTimeout() {
+ if (clickjackingTimeout) {
+ clearTimeout(clickjackingTimeout);
+ }
+ clickjackingTimeout = setTimeout(() => {
+ document.getElementById("openInsecure").classList.remove("disabled");
+ clickjackingTimeout = undefined;
+ }, clickjackingDelay);
+}
+
+function clearClickjackingTimeout() {
+ if (clickjackingTimeout) {
+ clearTimeout(clickjackingTimeout);
+ clickjackingTimeout = undefined;
+ }
+}
+
/* Initialize Page */
initPage();
diff --git a/toolkit/themes/shared/aboutHttpsOnlyError.css b/toolkit/themes/shared/aboutHttpsOnlyError.css
index 04937d8f45e..e8345aa6c4f 100644
--- a/toolkit/themes/shared/aboutHttpsOnlyError.css
+++ b/toolkit/themes/shared/aboutHttpsOnlyError.css
@@ -86,3 +86,10 @@ ul > li {
opacity: 1;
}
}
+
+/* We need this extra class instead of just adding a "disabled" attribute to the
+ * button, as we still need to receive JS events from it. */
+button.disabled {
+ opacity: var(--button-opacity-disabled);
+ color: var(--button-text-color-disabled);
+}
diff --git a/dom/security/test/https-first/browser_https_telemetry.js b/dom/security/test/https-first/browser_https_telemetry.js
index d952de1c94e..34242590e9e 100644
--- a/dom/security/test/https-first/browser_https_telemetry.js
+++ b/dom/security/test/https-first/browser_https_telemetry.js
@@ -375,7 +375,7 @@ add_task(async function () {
);
info("Waiting for openInsecureButton to be enabled.");
function callback() {
- if (!openInsecureButton.inert) {
+ if (!openInsecureButton.classList.contains("disabled")) {
observer.disconnect();
content.requestAnimationFrame(() => {
content.requestAnimationFrame(() => {
@@ -385,7 +385,7 @@ add_task(async function () {
}
}
const observer = new content.MutationObserver(callback);
- observer.observe(openInsecureButton, { attributeFilter: ["inert"] });
+ observer.observe(openInsecureButton, { attributeFilter: ["class"] });
callback();
});
diff --git a/dom/security/test/https-only/browser_continue_button_delay.js b/dom/security/test/https-only/browser_continue_button_delay.js
index 6bdee1610e0..a307687bde1 100644
--- a/dom/security/test/https-only/browser_continue_button_delay.js
+++ b/dom/security/test/https-only/browser_continue_button_delay.js
@@ -1,5 +1,21 @@
"use strict";
+// This test ensures the security delay (security.dialog_enable_delay) gets
+// properly applied to the "Continue" button on the HTTPS-Only error page. It
+// consists of the following checks:
+// 1. Check that the button gets enabled at the right time after a new tab is
+// loaded
+// 2. Check that the button gets disabled and re-enabled with the correct
+// timings on a focus loss due to a new tab being opened.
+// 3. Check that the button gets enabled with the correct timings when the
+// HTTPS-Only error page is loaded through the identity pane.
+
+// We specifically want a insecure url here that will fail to upgrade
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_URL = "http://untrusted.example.com";
+const TEST_PRINCIPAL =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(TEST_URL);
+
function waitForEnabledButton() {
return new Promise(resolve => {
const button = content.document.getElementById("openInsecure");
@@ -7,43 +23,28 @@ function waitForEnabledButton() {
for (const mutation of mutations) {
if (
mutation.type === "attributes" &&
- mutation.attributeName === "inert" &&
- !mutation.target.inert
+ mutation.attributeName === "class" &&
+ !mutation.target.classList.contains("disabled")
) {
resolve();
}
}
});
- observer.observe(button, { attributeFilter: ["inert"] });
+ observer.observe(button, { attributeFilter: ["class"] });
ok(
- button.inert,
- "The 'Continue to HTTP Site' button should be inert right after the error page is loaded."
+ button.classList.contains("disabled"),
+ "The 'Continue to HTTP Site' button should be disabled right after the error page is loaded/focused."
);
});
}
-add_task(async function () {
- waitForExplicitFinish();
-
- await SpecialPowers.pushPrefEnv({
- set: [["dom.security.https_only_mode", true]],
- });
-
- const specifiedDelay = Services.prefs.getIntPref(
- "security.dialog_enable_delay",
- 1000
- );
+const specifiedDelay = Services.prefs.getIntPref(
+ "security.dialog_enable_delay",
+ 1000
+);
- let loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
- info("Loading insecure page");
+async function waitForEnabledButtonAndCheckTiming() {
const startTime = Date.now();
- BrowserTestUtils.startLoadingURIString(
- gBrowser,
- // We specifically want a insecure url here that will fail to upgrade
- // eslint-disable-next-line @microsoft/sdl/no-insecure-url
- "http://untrusted.example.com:80"
- );
- await loaded;
await SpecialPowers.spawn(gBrowser.selectedBrowser, [], waitForEnabledButton);
const endTime = Date.now();
@@ -54,6 +55,49 @@ add_task(async function () {
specifiedDelay - 100,
`The observed delay (${observedDelay}ms) should be roughly the same or greater than the delay specified in "security.dialog_enable_delay" (${specifiedDelay}ms)`
);
+}
- finish();
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_only_mode", true]],
+ });
+
+ info("Loading insecure page");
+ let loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URL);
+ await loaded;
+ await waitForEnabledButtonAndCheckTiming();
+
+ info("Opening and closing a new tab");
+ let newTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ });
+ await BrowserTestUtils.removeTab(newTab);
+ await waitForEnabledButtonAndCheckTiming();
+
+ info("Loading page with exception");
+ await Services.perms.addFromPrincipal(
+ TEST_PRINCIPAL,
+ "https-only-load-insecure",
+ Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION
+ );
+ loaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URL);
+ await loaded;
+
+ info("Opening identity pane");
+ document.getElementById("identity-icon-box").click();
+ const identityPopup = document.getElementById("identity-popup");
+ ok(!!identityPopup, "Identity pane should exist");
+ await BrowserTestUtils.waitForPopupEvent(identityPopup, "shown");
+
+ info("Removing exception in identity pane");
+ const menulist = document.getElementById(
+ "identity-popup-security-httpsonlymode-menulist"
+ );
+ ok(!!menulist, "Identity pane should contain HTTPS-Only menulist");
+ loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ menulist.getItemAtIndex(0).doCommand();
+ await loaded;
+ await waitForEnabledButtonAndCheckTiming();
});
diff --git a/toolkit/components/httpsonlyerror/tests/browser/head.js b/toolkit/components/httpsonlyerror/tests/browser/head.js
index 7efbfbe8b50..ffe3c79df9e 100644
--- a/toolkit/components/httpsonlyerror/tests/browser/head.js
+++ b/toolkit/components/httpsonlyerror/tests/browser/head.js
@@ -81,7 +81,7 @@ function waitForAndClickOpenInsecureButton(browser) {
);
info("Waiting for openInsecureButton to be enabled.");
function callback() {
- if (!openInsecureButton.inert) {
+ if (!openInsecureButton.classList.contains("disabled")) {
info("openInsecureButton was enabled, waiting two frames.");
observer.disconnect();
content.requestAnimationFrame(() => {
@@ -93,7 +93,7 @@ function waitForAndClickOpenInsecureButton(browser) {
}
}
const observer = new content.MutationObserver(callback);
- observer.observe(openInsecureButton, { attributeFilter: ["inert"] });
+ observer.observe(openInsecureButton, { attributeFilter: ["class"] });
callback();
});
}
Regression Test / PoC
diff --git a/dom/security/test/https-first/browser_https_telemetry.js b/dom/security/test/https-first/browser_https_telemetry.js
index d952de1c94e..34242590e9e 100644
--- a/dom/security/test/https-first/browser_https_telemetry.js
+++ b/dom/security/test/https-first/browser_https_telemetry.js
@@ -375,7 +375,7 @@ add_task(async function () {
);
info("Waiting for openInsecureButton to be enabled.");
function callback() {
- if (!openInsecureButton.inert) {
+ if (!openInsecureButton.classList.contains("disabled")) {
observer.disconnect();
content.requestAnimationFrame(() => {
content.requestAnimationFrame(() => {
@@ -385,7 +385,7 @@ add_task(async function () {
}
}
const observer = new content.MutationObserver(callback);
- observer.observe(openInsecureButton, { attributeFilter: ["inert"] });
+ observer.observe(openInsecureButton, { attributeFilter: ["class"] });
callback();
});
diff --git a/dom/security/test/https-only/browser_continue_button_delay.js b/dom/security/test/https-only/browser_continue_button_delay.js
index 6bdee1610e0..a307687bde1 100644
--- a/dom/security/test/https-only/browser_continue_button_delay.js
+++ b/dom/security/test/https-only/browser_continue_button_delay.js
@@ -1,5 +1,21 @@
"use strict";
+// This test ensures the security delay (security.dialog_enable_delay) gets
+// properly applied to the "Continue" button on the HTTPS-Only error page. It
+// consists of the following checks:
+// 1. Check that the button gets enabled at the right time after a new tab is
+// loaded
+// 2. Check that the button gets disabled and re-enabled with the correct
+// timings on a focus loss due to a new tab being opened.
+// 3. Check that the button gets enabled with the correct timings when the
+// HTTPS-Only error page is loaded through the identity pane.
+
+// We specifically want a insecure url here that will fail to upgrade
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_URL = "http://untrusted.example.com";
+const TEST_PRINCIPAL =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(TEST_URL);
+
function waitForEnabledButton() {
return new Promise(resolve => {
const button = content.document.getElementById("openInsecure");
@@ -7,43 +23,28 @@ function waitForEnabledButton() {
for (const mutation of mutations) {
if (
mutation.type === "attributes" &&
- mutation.attributeName === "inert" &&
- !mutation.target.inert
+ mutation.attributeName === "class" &&
+ !mutation.target.classList.contains("disabled")
) {
resolve();
}
}
});
- observer.observe(button, { attributeFilter: ["inert"] });
+ observer.observe(button, { attributeFilter: ["class"] });
ok(
- button.inert,
- "The 'Continue to HTTP Site' button should be inert right after the error page is loaded."
+ button.classList.contains("disabled"),
+ "The 'Continue to HTTP Site' button should be disabled right after the error page is loaded/focused."
);
});
}
-add_task(async function () {
- waitForExplicitFinish();
-
- await SpecialPowers.pushPrefEnv({
- set: [["dom.security.https_only_mode", true]],
- });
-
- const specifiedDelay = Services.prefs.getIntPref(
- "security.dialog_enable_delay",
- 1000
- );
+const specifiedDelay = Services.prefs.getIntPref(
+ "security.dialog_enable_delay",
+ 1000
+);
- let loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
- info("Loading insecure page");
+async function waitForEnabledButtonAndCheckTiming() {
const startTime = Date.now();
- BrowserTestUtils.startLoadingURIString(
- gBrowser,
- // We specifically want a insecure url here that will fail to upgrade
- // eslint-disable-next-line @microsoft/sdl/no-insecure-url
- "http://untrusted.example.com:80"
- );
- await loaded;
await SpecialPowers.spawn(gBrowser.selectedBrowser, [], waitForEnabledButton);
const endTime = Date.now();
@@ -54,6 +55,49 @@ add_task(async function () {
specifiedDelay - 100,
`The observed delay (${observedDelay}ms) should be roughly the same or greater than the delay specified in "security.dialog_enable_delay" (${specifiedDelay}ms)`
);
+}
- finish();
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_only_mode", true]],
+ });
+
+ info("Loading insecure page");
+ let loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URL);
+ await loaded;
+ await waitForEnabledButtonAndCheckTiming();
+
+ info("Opening and closing a new tab");
+ let newTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ });
+ await BrowserTestUtils.removeTab(newTab);
+ await waitForEnabledButtonAndCheckTiming();
+
+ info("Loading page with exception");
+ await Services.perms.addFromPrincipal(
+ TEST_PRINCIPAL,
+ "https-only-load-insecure",
+ Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION
+ );
+ loaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URL);
+ await loaded;
+
+ info("Opening identity pane");
+ document.getElementById("identity-icon-box").click();
+ const identityPopup = document.getElementById("identity-popup");
+ ok(!!identityPopup, "Identity pane should exist");
+ await BrowserTestUtils.waitForPopupEvent(identityPopup, "shown");
+
+ info("Removing exception in identity pane");
+ const menulist = document.getElementById(
+ "identity-popup-security-httpsonlymode-menulist"
+ );
+ ok(!!menulist, "Identity pane should contain HTTPS-Only menulist");
+ loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ menulist.getItemAtIndex(0).doCommand();
+ await loaded;
+ await waitForEnabledButtonAndCheckTiming();
});
diff --git a/toolkit/components/httpsonlyerror/tests/browser/head.js b/toolkit/components/httpsonlyerror/tests/browser/head.js
index 7efbfbe8b50..ffe3c79df9e 100644
--- a/toolkit/components/httpsonlyerror/tests/browser/head.js
+++ b/toolkit/components/httpsonlyerror/tests/browser/head.js
@@ -81,7 +81,7 @@ function waitForAndClickOpenInsecureButton(browser) {
);
info("Waiting for openInsecureButton to be enabled.");
function callback() {
- if (!openInsecureButton.inert) {
+ if (!openInsecureButton.classList.contains("disabled")) {
info("openInsecureButton was enabled, waiting two frames.");
observer.disconnect();
content.requestAnimationFrame(() => {
@@ -93,7 +93,7 @@ function waitForAndClickOpenInsecureButton(browser) {
}
}
const observer = new content.MutationObserver(callback);
- observer.observe(openInsecureButton, { attributeFilter: ["inert"] });
+ observer.observe(openInsecureButton, { attributeFilter: ["class"] });
callback();
});
}