CVE-2025-6433
Overview
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 byWebAuthnTransactionParent::RecvRequestRegister/RecvRequestSignindom/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 annsITransportSecurityInfodescribing 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.
Attack Path
- 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).
- 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.
- Challenge issuance
The page calls
navigator.credentials.create()/.get(); the request reachesRecvRequestRegister/RecvRequestSign. - Missing gate (pre-patch)
Only
IsWebAuthnAllowedForPrincipalruns and passes; the handler prompts the user to complete a WebAuthn ceremony. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchdom/webauthn/WebAuthnUtil.cpp |
modified | |
ifdom/webauthn/tests/browser/browser_webauthn_cert_override.js |
modified |
Files Changed
dom/webauthn/WebAuthnTransactionParent.cppdom/webauthn/WebAuthnUtil.cppdom/webauthn/WebAuthnUtil.hdom/webauthn/tests/browser/browser.tomldom/webauthn/tests/browser/browser_webauthn_cert_override.js
Audit Directions
- Other transport-sensitive DOM gatesReview 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 policyConfirm the intended policy that
ERROR_TIMEremains allowed; audit whether any error category should additionally be refused, and whether unknown future categories safely fall through thedefault: return false. - Child-process trustThe check is correctly in the parent; verify no child-process WebAuthn entry point can bypass
WebAuthnTransactionParentand reach the authenticator without this gate.
Patch
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)
+);
Regression Test / PoC
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)
+);