Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebAuthentication
DescriptionInsufficient policy enforcement in WebAuthentication
ComponentWebAuthentication
Bug ClassLogic Error
Tracker500044225
Fix commit55ad798ca305 (chromium/src) +90/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • chrome/android/javatests/src/org/chromium/chrome/browser/webauth/WebauthnTestUtils.java
  • components/webauthn/android/java/src/org/chromium/components/webauthn/Fido2CredentialRequest.java
  • components/webauthn/android/junit/src/org/chromium/components/webauthn/Fido2CredentialRequestRobolectricTest.java
From 55ad798ca305258afc115de8b97efa76279bfdcd Mon Sep 17 00:00:00 2001
From: Ken Buchanan <[email protected]>
Date: Mon, 20 Apr 2026 16:35:57 -0700
Subject: [PATCH] [WebAuthn] Improve handling of Immediate password requests on Android

Password requests from `navigator.credential.get()` have never been
permitted in iframes.

For Immediate requests, on desktop passwords are ignored for non-
main frames. This change makes Android behave in the same way:
- If a password-only request is received from an iframe, it is
  rejected right away in the Java handler.
- If a password+passkey request is received from an iframe, it
  turns it into a passkey-only request.

Fixed: 500044225
Change-Id: I64f846caa6771ef74ade8b9abaca65f47b9c755b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7779667
Commit-Queue: Ken Buchanan <[email protected]>
Commit-Queue: Martin Kreichgauer <[email protected]>
Auto-Submit: Ken Buchanan <[email protected]>
Reviewed-by: Martin Kreichgauer <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1617856}
---

diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/webauth/WebauthnTestUtils.java b/chrome/android/javatests/src/org/chromium/chrome/browser/webauth/WebauthnTestUtils.java
index 94d40e93..9af1748 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/webauth/WebauthnTestUtils.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/webauth/WebauthnTestUtils.java
@@ -353,6 +353,11 @@
             return Origin.create(mLastUrl);
         }
 
+        @Override
+        public RenderFrameHost getMainFrame() {
+            return this;
+        }
+
         public void setLastCommittedUrl(GURL url) {
             mLastUrl = url;
         }
diff --git a/components/webauthn/android/java/src/org/chromium/components/webauthn/Fido2CredentialRequest.java b/components/webauthn/android/java/src/org/chromium/components/webauthn/Fido2CredentialRequest.java
index 99756a9..94e7eed 100644
--- a/components/webauthn/android/java/src/org/chromium/components/webauthn/Fido2CredentialRequest.java
+++ b/components/webauthn/android/java/src/org/chromium/components/webauthn/Fido2CredentialRequest.java
@@ -457,6 +457,20 @@
     @SuppressWarnings("NewApi")
     private void handlePasswordOnlyImmediateRequest(GetCredentialOptions options, Origin origin) {
         assert options.password && options.mediation == Mediation.IMMEDIATE;
+
+        RenderFrameHost frameHost = mAuthenticationContextProvider.getRenderFrameHost();
+        if (frameHost == null || frameHost.getMainFrame() != frameHost) {
+            logError(
+                    TAG,
+                    "Password-only immediate mediation requests can only be issued from the main"
+                            + " frame");
+            returnErrorAndResetCallback(
+                    AuthenticatorStatus.NOT_ALLOWED_ERROR,
+                    /* response= */ null,
+                    /* credentialRequestResult= */ null);
+            return;
+        }
+
         final String originString = convertOriginToString(origin);
         if (!isChrome(mAuthenticationContextProvider.getWebContents())) {
             if (CredManSupportProvider.getCredManSupportForWebView() == CredManSupport.DISABLED) {
@@ -558,6 +572,13 @@
                 log(TAG, "Immediate Get called in Incognito mode");
                 mBarrier.setImmediateIncognito();
             }
+            if (frameHost.getMainFrame() != frameHost && options.password) {
+                log(
+                        TAG,
+                        "Immediate Get request in an iframe cannot access passwords. Only passkeys"
+                                + " will be available.");
+                options.password = false;
+            }
         }
 
         @Nullable Origin remoteDesktopOrigin = null;
diff --git a/components/webauthn/android/junit/src/org/chromium/components/webauthn/Fido2CredentialRequestRobolectricTest.java b/components/webauthn/android/junit/src/org/chromium/components/webauthn/Fido2CredentialRequestRobolectricTest.java
index 45e5018..5546cbd 100644
--- a/components/webauthn/android/junit/src/org/chromium/components/webauthn/Fido2CredentialRequestRobolectricTest.java
+++ b/components/webauthn/android/junit/src/org/chromium/components/webauthn/Fido2CredentialRequestRobolectricTest.java
@@ -12,6 +12,7 @@
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.notNull;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -772,6 +773,69 @@
 
     @Test
     @SmallTest
+    public void testImmediateGetCredential_passwordOnly_subframe_fails() {
+        GetCredentialOptions options = new GetCredentialOptions();
+        options.publicKey = null;
+        options.password = true;
+        options.mediation = Mediation.IMMEDIATE;
+
+        RenderFrameHost subframe = Mockito.mock(RenderFrameHost.class);
+        doReturn(subframe).when(mAuthenticationContextProviderMock).getRenderFrameHost();
+        doReturn(mFrameHost).when(subframe).getMainFrame();
+
+        setUpGetCredentialCallback();
+        mRequest.handleGetCredentialRequest(options, mOrigin, mOrigin, /* payment= */ null);
+
+        assertThat(mCallback.getStatus())
+                .isEqualTo(Integer.valueOf(AuthenticatorStatus.NOT_ALLOWED_ERROR));
+    }
+
+    @Test
+    @SmallTest
+    public void testImmediateGetCredential_iframeWithPassword_passwordDisabled() {
+        setGetCredentialRequestOptions(/* hasAllowList= */ false);
+        mRequestOptions.mediation = Mediation.IMMEDIATE;
+        mRequestOptions.password = true;
+
+        RenderFrameHost subframe = Mockito.mock(RenderFrameHost.class);
+
+        // `doReturn` overrides the existing stub from setUp.
+        doReturn(subframe).when(mAuthenticationContextProviderMock).getRenderFrameHost();
+        doReturn(mFrameHost).when(subframe).getMainFrame();
+
+        GURL gurl =
+                new GURL(
+                        "https://subdomain.example.test:443/content/test/data/android/authenticator.html");
+        doReturn(gurl).when(subframe).getLastCommittedURL();
+        doReturn(mOrigin).when(subframe).getLastCommittedOrigin();
+
+        doAnswer(
+                        (invocation) -> {
+                            ((Callback<WebAuthSecurityChecksResults>) invocation.getArguments()[5])
+                                    .onResult(
+                                            new WebAuthSecurityChecksResults(
+                                                    AuthenticatorStatus.SUCCESS, false));
+                            return null;
+                        })
+                .when(subframe)
+                .performGetAssertionWebAuthSecurityChecks(
+                        any(), any(), anyBoolean(), any(), any(), any());
+
+        CredManSupportProvider.setupForTesting(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, true);
+
+        mRequest.handleGetCredentialRequest(mRequestOptions, mOrigin, mOrigin, /* payment= */ null);
+
+        ArgumentCaptor<GetCredentialOptions> optionsCaptor =
+                ArgumentCaptor.forClass(GetCredentialOptions.class);
+        verify(mCredManHelperMock)
+                .startPrefetchRequest(
+                        optionsCaptor.capture(), any(), any(), any(), any(), any(), anyBoolean());
+
+        assertThat(optionsCaptor.getValue().password).isFalse();
+    }
+
+    @Test
+    @SmallTest
     public void testReportRequest_noSignalArgumentsSet_unknownError() {
         PublicKeyCredentialReportOptions options = new PublicKeyCredentialReportOptions();
         options.relyingPartyId = "rpId";
Loading diff…

Original Bug Report

reported by [email protected]

Potential Cross-Origin Password Leak via WebAuthn Request Clobbering on Android

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: A logic flaw in Android’s WebAuthn implementation potentially allows a compromised cross-origin subframe to steal plaintext passwords from the main frame. The issue occurs when a subframe’s password-only request bypasses origin checks and clobbers the callback state of a pending main frame request. When the user interacts with the main frame’s TouchToFill UI, their credentials are sent to the attacker.

Affected files:

  • chrome/browser/webauthn/android/webauthn_request_delegate_android.cc
  • components/webauthn/android/webauthn_browser_bridge.cc
  • chrome/browser/webauthn/android/chrome_webauthn_client_android.cc
  • chrome/browser/touch_to_fill/password_manager/touch_to_fill_controller_webauthn_delegate.cc

Estimated timestamp from git blame: 2026-01-07

Summary

There is a potential vulnerability in the Android WebAuthn implementation that allows a compromised cross-origin subframe to bypass Site Isolation and exfiltrate plaintext passwords belonging to the main frame. The vulnerability relies on two distinct logic flaws: a missing security check in the Java layer for password-only requests, and unsafe state clobbering in the C++ UI delegate.

(Note: The steps described below are potential vectors identified through code analysis; our tooling agent does not currently run executable proofs-of-concept.)

Technical Details

1. Java Origin Check Bypass When a renderer sends a blink::mojom::Authenticator::GetCredential Mojo message on Android, it is routed to AuthenticatorImpl.java. If a request is crafted with options.publicKey == null and options.password == true, Fido2CredentialRequest::handleGetCredentialRequest routes it to handlePasswordOnlyImmediateRequest and immediately returns. This early return skips the frameHost.performGetAssertionWebAuthSecurityChecks() call, which is responsible for enforcing ancestor origin validation (ValidateAncestorOrigins). Consequently, cross-origin subframes are not blocked from making password-only requests.

2. C++ Callback Clobbering The Java layer forwards the request to the C++ delegate, WebAuthnRequestDelegateAndroid. This delegate is a per-tab singleton (WebContentsUserData). In OnWebAuthnRequestPending, the delegate unconditionally saves the incoming callbacks (e.g., password_callback_ = std::move(password_callback)). If the main frame already has a pending request, its callbacks are clobbered. Furthermore, the delegate checks if (frame_host->IsInPrimaryMainFrame()). Because the malicious request comes from a subframe, this evaluates to false, causing an early return. Crucially, this early return fails to dismiss the active TouchToFill UI that was spawned by the main frame.

Potential Attack Scenario

An attacker could potentially exploit this by compromising a subframe renderer:

  1. A user navigates to a victim main frame (e.g., bank.com) that embeds a cross-origin subframe (attacker.com).
  2. The main frame initiates a legitimate navigator.credentials.get({password: true, mediation: 'immediate'}) request. The browser displays the TouchToFill bottom sheet containing the user’s saved passwords for bank.com.
  3. The attacker’s compromised subframe renderer crafts and sends a raw Mojo GetCredential request with publicKey: null and password: true.
  4. The Java layer skips origin checks, and the C++ delegate unconditionally clobbers the password_callback_ with the attacker’s callback. The bank.com TouchToFill UI remains visible.
  5. The user, intending to log into bank.com, taps their saved password.
  6. The clobbered callback is executed, and the plaintext credentials for bank.com are routed via Mojo to the attacker.com renderer.

Suggested Fix

  1. Java Layer (Fido2CredentialRequest.java): Ensure that performGetAssertionWebAuthSecurityChecks (or an equivalent origin validation routine) is strictly enforced for password-only immediate requests before calling handlePasswordOnlyImmediateRequest.
  2. C++ Layer (WebAuthnRequestDelegateAndroid::OnWebAuthnRequestPending): Prevent concurrent requests from silently clobbering shared state. If a request is already active (or if a TouchToFill UI is currently showing), incoming requests should either be rejected, or the existing UI should be explicitly dismissed before overwriting the callbacks.

Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker