Low CVSS 9.8 firefox Policy Bypass 🔧 Commit mapped

Overview

Low
Severity
9.8
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionIf a user visited a webpage with an invalid TLS certificate, and granted an exception, the webpage was able to provide a WebAuthn challenge that the user would be prompted to complete. This is in violation of the WebAuthN spec which requires "a secure transport established without errors".
ComponentDOM
Bug ClassPolicy Bypass
Tracker1954033
Fix commitdca3898ce43c (firefox) +126/-0
CWECWE-295
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSimon
Disclosed2025-06-24

Background

WebAuthn
The Web Authentication API lets a page call navigator.credentials.create() / .get() to register or assert a FIDO credential. In Firefox these calls cross IPC to the parent process and are serviced by WebAuthnTransactionParent::RecvRequestRegister / RecvRequestSign in dom/webauthn/WebAuthnTransactionParent.cpp.
`WindowGlobalParent` / `nsIPrincipal`
The parent-side object for a document’s window. It exposes DocumentPrincipal() (origin/security context) and, added here, GetSecurityInfo() which returns an nsITransportSecurityInfo describing how the TLS connection was established.
`nsITransportSecurityInfo::OverridableErrorCategory`
Classifies a certificate error the user can click through: ERROR_UNSET (no error), ERROR_TIME (expired/not-yet-valid), ERROR_TRUST (untrusted issuer), ERROR_DOMAIN (name mismatch).
Cert exception ("override")
When a TLS error occurs the user can add a permanent exception and the page loads anyway, over a connection that failed validation. The WebAuthn spec requires “a secure transport established without errors.”

Root Cause Analysis

RecvRequestRegister and RecvRequestSign gated WebAuthn only on the document’s principal via IsWebAuthnAllowedForPrincipal(principal) (a scheme/secure-context check). Neither function inspected the actual transport that delivered the document. If the user had added a certificate exception, the page was served over a connection whose certificate validation had failed, yet the principal still looked like an ordinary https:// secure context, so the principal check passed and the WebAuthn request proceeded. This violates the WebAuthn requirement that the transport be “established without errors.”

The patch introduces IsWebAuthnAllowedForTransportSecurityInfo(nsITransportSecurityInfo*) in dom/webauthn/WebAuthnUtil.cpp and calls it first in both IPC handlers. The helper reads GetOverridableErrorCategory(): it returns true only for ERROR_UNSET and ERROR_TIME, and false for ERROR_TRUST, ERROR_DOMAIN, any unknown category, a null aSecurityInfo, or a failed GetOverridableErrorCategory call. When it returns false the handler resolves with NS_ERROR_DOM_SECURITY_ERR and returns, blocking the operation. Note the deliberate asymmetry the diff encodes: a clock/time error (ERROR_TIME) is still permitted, while trust and domain-name overrides are not — matching the new browser_webauthn_cert_override.js test which expects success for expired.example.com but a security error for untrusted.example.com and no-subject-alt-name.example.com.

Key insight
WebAuthn authorization was decided purely from the document principal and never from the TLS validation result, so a user-granted certificate override let a page with a broken/forged certificate issue WebAuthn challenges as if on a clean secure transport.

Attack Path

  1. Attacker positioning Attacker controls a server presenting an untrusted or name-mismatched certificate for a target origin (e.g. a MITM or a look-alike host), where chain trust or domain validation fails (inferred reachability; the diff proves only the server-side gate).
  2. User override The victim reaches the site, sees the certificate error page, and clicks through to add an exception, loading the page over the invalid-cert connection.
  3. Challenge issuance The page calls navigator.credentials.create() / .get(); the request reaches RecvRequestRegister / RecvRequestSign.
  4. Missing gate (pre-patch) Only IsWebAuthnAllowedForPrincipal runs and passes; the handler prompts the user to complete a WebAuthn ceremony.
  5. Primitive The attacker obtains a WebAuthn registration or assertion over a transport that failed certificate validation, contrary to spec, undermining the channel-binding/phishing-resistance guarantees WebAuthn is meant to provide.

Impact Assessment

This is a security-policy enforcement gap, not a memory-safety bug. It executes in the parent process but confers no memory primitive and no code execution; the advisory’s CVSS 9.8 is not substantiated by this diff (impact is labeled “low”). The gained capability is the ability to run a WebAuthn ceremony over a certificate-override connection, which weakens the phishing-resistance WebAuthn promises. It is gated on the user first explicitly granting a certificate exception for the origin, which limits real-world exploitability.

Changed Functions

FunctionChangeNotes
switch
dom/webauthn/WebAuthnUtil.cpp
modified
if
dom/webauthn/tests/browser/browser_webauthn_cert_override.js
modified

Files Changed

  • dom/webauthn/WebAuthnTransactionParent.cpp
  • dom/webauthn/WebAuthnUtil.cpp
  • dom/webauthn/WebAuthnUtil.h
  • dom/webauthn/tests/browser/browser.toml
  • dom/webauthn/tests/browser/browser_webauthn_cert_override.js

Audit Directions

  • Other transport-sensitive DOM gates
    Review other secure-context-only features (WebAuthn conditional mediation, Web Payments, credential storage, service worker registration) that check only the principal and not GetOverridableErrorCategory().
  • Override category policy
    Confirm the intended policy that ERROR_TIME remains allowed; audit whether any error category should additionally be refused, and whether unknown future categories safely fall through the default: return false.
  • Child-process trust
    The check is correctly in the parent; verify no child-process WebAuthn entry point can bypass WebAuthnTransactionParent and reach the authenticator without this gate.
diff --git a/dom/webauthn/WebAuthnTransactionParent.cpp b/dom/webauthn/WebAuthnTransactionParent.cpp
index 3c704b9620e..f83880bc825 100644
--- a/dom/webauthn/WebAuthnTransactionParent.cpp
+++ b/dom/webauthn/WebAuthnTransactionParent.cpp
@@ -121,6 +121,11 @@ mozilla::ipc::IPCResult WebAuthnTransactionParent::RecvRequestRegister(
   WindowGlobalParent* manager = static_cast<WindowGlobalParent*>(Manager());
   nsIPrincipal* principal = manager->DocumentPrincipal();
 
+  if (!IsWebAuthnAllowedForTransportSecurityInfo(manager->GetSecurityInfo())) {
+    aResolver(NS_ERROR_DOM_SECURITY_ERR);
+    return IPC_OK();
+  }
+
   if (!IsWebAuthnAllowedForPrincipal(principal)) {
     aResolver(NS_ERROR_DOM_SECURITY_ERR);
     return IPC_OK();
@@ -311,6 +316,11 @@ mozilla::ipc::IPCResult WebAuthnTransactionParent::RecvRequestSign(
   WindowGlobalParent* manager = static_cast<WindowGlobalParent*>(Manager());
   nsIPrincipal* principal = manager->DocumentPrincipal();
 
+  if (!IsWebAuthnAllowedForTransportSecurityInfo(manager->GetSecurityInfo())) {
+    aResolver(NS_ERROR_DOM_SECURITY_ERR);
+    return IPC_OK();
+  }
+
   if (!IsWebAuthnAllowedForPrincipal(principal)) {
     aResolver(NS_ERROR_DOM_SECURITY_ERR);
     return IPC_OK();
diff --git a/dom/webauthn/WebAuthnUtil.cpp b/dom/webauthn/WebAuthnUtil.cpp
index 5fb65ac33ce..96d20e6fbf3 100644
--- a/dom/webauthn/WebAuthnUtil.cpp
+++ b/dom/webauthn/WebAuthnUtil.cpp
@@ -132,6 +132,28 @@ bool IsWebAuthnAllowedForPrincipal(const nsCOMPtr<nsIPrincipal>& aPrincipal) {
   return true;
 }
 
+bool IsWebAuthnAllowedForTransportSecurityInfo(
+    nsITransportSecurityInfo* aSecurityInfo) {
+  nsITransportSecurityInfo::OverridableErrorCategory overridableErrorCategory;
+  if (!aSecurityInfo || NS_FAILED(aSecurityInfo->GetOverridableErrorCategory(
+                            &overridableErrorCategory))) {
+    return false;
+  }
+
+  switch (overridableErrorCategory) {
+    case nsITransportSecurityInfo::OverridableErrorCategory::ERROR_UNSET:
+      return true;
+    case nsITransportSecurityInfo::OverridableErrorCategory::ERROR_TIME:
+      return true;
+    case nsITransportSecurityInfo::OverridableErrorCategory::ERROR_TRUST:
+      return false;
+    case nsITransportSecurityInfo::OverridableErrorCategory::ERROR_DOMAIN:
+      return false;
+    default:
+      return false;
+  }
+}
+
 bool IsValidRpId(const nsCOMPtr<nsIPrincipal>& aPrincipal,
                  const nsACString& aRpId) {
   // This checks two of the conditions defined in
diff --git a/dom/webauthn/WebAuthnUtil.h b/dom/webauthn/WebAuthnUtil.h
index 68f50a2dcff..0806db47e06 100644
--- a/dom/webauthn/WebAuthnUtil.h
+++ b/dom/webauthn/WebAuthnUtil.h
@@ -19,6 +19,9 @@ bool IsWebAuthnAllowedInDocument(const nsCOMPtr<Document>& aDoc);
 
 bool IsWebAuthnAllowedForPrincipal(const nsCOMPtr<nsIPrincipal>& aPrincipal);
 
+bool IsWebAuthnAllowedForTransportSecurityInfo(
+    nsITransportSecurityInfo* aSecurityInfo);
+
 nsresult DefaultRpId(const nsCOMPtr<nsIPrincipal>& aPrincipal,
                      /* out */ nsACString& aRpId);
 
diff --git a/dom/webauthn/tests/browser/browser.toml b/dom/webauthn/tests/browser/browser.toml
index 9bca41566ef..0aaeb5eb3c4 100644
--- a/dom/webauthn/tests/browser/browser.toml
+++ b/dom/webauthn/tests/browser/browser.toml
@@ -21,6 +21,8 @@ run-if = ["os != 'win'"] # Test not relevant on 1903+
 ["browser_fido_appid_extension.js"]
 run-if = ["os != 'win'"] # Test not relevant on 1903+
 
+["browser_webauthn_cert_override.js"]
+
 ["browser_webauthn_conditional_mediation.js"]
 
 ["browser_webauthn_ipaddress.js"]
diff --git a/dom/webauthn/tests/browser/browser_webauthn_cert_override.js b/dom/webauthn/tests/browser/browser_webauthn_cert_override.js
new file mode 100644
index 00000000000..abe77d95523
--- /dev/null
+++ b/dom/webauthn/tests/browser/browser_webauthn_cert_override.js
@@ -0,0 +1,89 @@
+/* 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";
+
+add_virtual_authenticator();
+
+let expectSecurityError = expectError("Security");
+
+async function test_webauthn_with_cert_override(
+  aTestDomain,
+  aExpectSecurityError
+) {
+  let certOverrideService = Cc[
+    "@mozilla.org/security/certoverride;1"
+  ].getService(Ci.nsICertOverrideService);
+
+  let testURL = "https://" + aTestDomain;
+  let certErrorLoaded;
+  let tab = await BrowserTestUtils.openNewForegroundTab(
+    gBrowser,
+    () => {
+      gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, testURL);
+      let browser = gBrowser.selectedBrowser;
+      certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+    },
+    false
+  );
+  info("Waiting for cert error page.");
+  await certErrorLoaded;
+
+  let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+  info("Adding certificate error override.");
+  await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+    let doc = content.document;
+    let exceptionButton = doc.getElementById("exceptionDialogButton");
+    exceptionButton.click();
+  });
+
+  info("Waiting for page load.");
+  await loaded;
+
+  await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+    let doc = content.document;
+    ok(
+      !doc.documentURI.startsWith("about:certerror"),
+      "Exception has been added."
+    );
+  });
+
+  let makeCredPromise = promiseWebAuthnMakeCredential(tab, "none", "preferred");
+  if (aExpectSecurityError) {
+    await makeCredPromise.then(arrivingHereIsBad).catch(expectSecurityError);
+    ok(
+      true,
+      "Calling navigator.credentials.create() results in a security error"
+    );
+  } else {
+    await makeCredPromise.catch(arrivingHereIsBad);
+    ok(true, "Calling navigator.credentials.create() is allowed");
+  }
+
+  let getAssertionPromise = promiseWebAuthnGetAssertionDiscoverable(tab);
+  if (aExpectSecurityError) {
+    await getAssertionPromise
+      .then(arrivingHereIsBad)
+      .catch(expectSecurityError);
+    ok(true, "Calling navigator.credentials.get() results in a security error");
+  } else {
+    await getAssertionPromise.catch(arrivingHereIsBad);
+    ok(true, "Calling navigator.credentials.get() results in a security error");
+  }
+
+  certOverrideService.clearValidityOverride(aTestDomain, -1, {});
+
+  loaded = BrowserTestUtils.waitForErrorPage(tab.linkedBrowser);
+  BrowserCommands.reloadSkipCache();
+  await loaded;
+
+  BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+add_task(() => test_webauthn_with_cert_override("expired.example.com", false));
+add_task(() => test_webauthn_with_cert_override("untrusted.example.com", true));
+add_task(() =>
+  test_webauthn_with_cert_override("no-subject-alt-name.example.com", true)
+);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/dom/webauthn/tests/browser/browser.toml b/dom/webauthn/tests/browser/browser.toml
index 9bca41566ef..0aaeb5eb3c4 100644
--- a/dom/webauthn/tests/browser/browser.toml
+++ b/dom/webauthn/tests/browser/browser.toml
@@ -21,6 +21,8 @@ run-if = ["os != 'win'"] # Test not relevant on 1903+
 ["browser_fido_appid_extension.js"]
 run-if = ["os != 'win'"] # Test not relevant on 1903+
 
+["browser_webauthn_cert_override.js"]
+
 ["browser_webauthn_conditional_mediation.js"]
 
 ["browser_webauthn_ipaddress.js"]
diff --git a/dom/webauthn/tests/browser/browser_webauthn_cert_override.js b/dom/webauthn/tests/browser/browser_webauthn_cert_override.js
new file mode 100644
index 00000000000..abe77d95523
--- /dev/null
+++ b/dom/webauthn/tests/browser/browser_webauthn_cert_override.js
@@ -0,0 +1,89 @@
+/* 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";
+
+add_virtual_authenticator();
+
+let expectSecurityError = expectError("Security");
+
+async function test_webauthn_with_cert_override(
+  aTestDomain,
+  aExpectSecurityError
+) {
+  let certOverrideService = Cc[
+    "@mozilla.org/security/certoverride;1"
+  ].getService(Ci.nsICertOverrideService);
+
+  let testURL = "https://" + aTestDomain;
+  let certErrorLoaded;
+  let tab = await BrowserTestUtils.openNewForegroundTab(
+    gBrowser,
+    () => {
+      gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, testURL);
+      let browser = gBrowser.selectedBrowser;
+      certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+    },
+    false
+  );
+  info("Waiting for cert error page.");
+  await certErrorLoaded;
+
+  let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+  info("Adding certificate error override.");
+  await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+    let doc = content.document;
+    let exceptionButton = doc.getElementById("exceptionDialogButton");
+    exceptionButton.click();
+  });
+
+  info("Waiting for page load.");
+  await loaded;
+
+  await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+    let doc = content.document;
+    ok(
+      !doc.documentURI.startsWith("about:certerror"),
+      "Exception has been added."
+    );
+  });
+
+  let makeCredPromise = promiseWebAuthnMakeCredential(tab, "none", "preferred");
+  if (aExpectSecurityError) {
+    await makeCredPromise.then(arrivingHereIsBad).catch(expectSecurityError);
+    ok(
+      true,
+      "Calling navigator.credentials.create() results in a security error"
+    );
+  } else {
+    await makeCredPromise.catch(arrivingHereIsBad);
+    ok(true, "Calling navigator.credentials.create() is allowed");
+  }
+
+  let getAssertionPromise = promiseWebAuthnGetAssertionDiscoverable(tab);
+  if (aExpectSecurityError) {
+    await getAssertionPromise
+      .then(arrivingHereIsBad)
+      .catch(expectSecurityError);
+    ok(true, "Calling navigator.credentials.get() results in a security error");
+  } else {
+    await getAssertionPromise.catch(arrivingHereIsBad);
+    ok(true, "Calling navigator.credentials.get() results in a security error");
+  }
+
+  certOverrideService.clearValidityOverride(aTestDomain, -1, {});
+
+  loaded = BrowserTestUtils.waitForErrorPage(tab.linkedBrowser);
+  BrowserCommands.reloadSkipCache();
+  await loaded;
+
+  BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+add_task(() => test_webauthn_with_cert_override("expired.example.com", false));
+add_task(() => test_webauthn_with_cert_override("untrusted.example.com", true));
+add_task(() =>
+  test_webauthn_with_cert_override("no-subject-alt-name.example.com", true)
+);
Loading diff…