CVE-2026-87583
Overview
Background
- Touch To Fill (TTF)
- the Android bottom-sheet UI Chrome shows to offer passwords/passkeys for a login request, which displays the origin the credentials are being requested for.
- "No Passkeys" bottom sheet
- the TTF variant shown for a WebAuthn (
navigator.credentials.get) request when no matching passkey exists, presenting the requesting site’s identity to the user. - `GetPrimaryMainFrame()`
- a
content::WebContentsaccessor that returns the top-level (outermost) frame of a tab, i.e. the parent document, not any embedded subframe. - `render_frame_host()`
- the
content::RenderFrameHostof the specific frame that actually initiated the WebAuthn request, which may be a cross-origin subframe.
Root Cause Analysis
The Android WebAuthn TTF delegate computed the origin/URL shown in the “No Passkeys” sheet from the wrong frame. In TouchToFillPasswordManagerWebAuthnDelegate::GetFrameOrigin(), the removed code called credential_receiver_->web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin(), and GetFrameUrl() called web_contents()->GetLastCommittedURL() — both of which always resolve to the top-level document, ignoring whichever subframe triggered the request. The security invariant that the displayed origin must match the origin that actually initiated the credential request was therefore violated whenever the request came from a subframe.
The fix routes both getters through a new TouchToFillCredentialReceiver::GetFrameUrl()/GetFrameOrigin() interface, implemented by WebAuthnRequestDelegateAndroid as render_frame_host().GetLastCommittedURL()/GetLastCommittedOrigin(), so the identity shown is that of the true requesting frame. The change also adds a credential_receiver_ null guard (previously the raw credential_receiver_->web_contents() deref could crash) and makes the getters const.
GetPrimaryMainFrame()) instead of the requesting frame’s origin; the fix delegates to the receiver’s render_frame_host(), so a subframe request now displays its own origin rather than the parent’s.Attack Path
- Embed a cross-origin subframe An attacker-controlled page (or a page under a trusted-looking parent) hosts a subframe from a different origin that is permitted to make WebAuthn calls.
- Trigger a WebAuthn get from the subframe
The subframe calls
navigator.credentials.get({publicKey: ...})for a relying party where the user has no passkey, causing Chrome on Android to prepare the “No Passkeys” bottom sheet. - Origin resolved from the wrong frame
GetFrameOrigin()/GetFrameUrl()return the top-level document’s origin viaGetPrimaryMainFrame()instead of the subframe’s, so the sheet labels the request with the parent frame’s identity. - User misled about the requester The victim sees a familiar/trusted parent origin on the passkey prompt while the actual request belongs to the different subframe origin, enabling UI misrepresentation about which site is asking for credentials.
Impact Assessment
(inferred)).Changed Functions
| Function | Change | Notes |
|---|---|---|
GURLchrome/browser/webauthn/touch_to_fill_credential_receiver.h |
modified | |
WebContentschrome/browser/webauthn/touch_to_fill_credential_receiver.h |
modified | |
TEST_Fchrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc |
modified |
Files Changed
chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.hchrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.ccchrome/browser/webauthn/android/webauthn_request_delegate_android.hchrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_delegate.hchrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.hchrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.ccchrome/browser/webauthn/android/webauthn_request_delegate_android.ccchrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.ccchrome/browser/webauthn/touch_to_fill_credential_receiver.h
Audit Directions
- Main-frame-vs-requesting-frame confusionAudit any security-relevant UI that displays an origin/URL for
GetPrimaryMainFrame()orWebContents::GetLastCommittedURL()usage where the value should come from the specificRenderFrameHostthat initiated the action, especially in subframe-capable flows (WebAuthn, payments, permissions). - Origin display in credential/permission promptsReview other TTF and WebAuthn delegate/receiver getters (and CredMan, autofill sheets) to confirm the shown origin is derived from the requesting frame, not the tab or top-level document.
- Missing null guards on receiver/web_contents chainsSearch for
credential_receiver_->orweb_contents()->dereferences without a preceding null check, since the removed code could crash when the receiver was absent.
Patch
From 49e2dbf4709abf6b1de30e5a27bcdac76e872bec Mon Sep 17 00:00:00 2001 From: Friedrich Horschig <[email protected]> Date: Tue, 28 Jul 2026 06:46:55 -0700 Subject: [PATCH] [Android][WebAuthn] Fix frame origin retrieval in TTF Delegate Prior to this CL, the origin of the parent frame would always be used when the "No Passkeys bottom sheet" shows up for a request originating from a subframe. This CL fixes the display issue and the const correctness of the getter. Fixed: 533116484 Change-Id: Ib1ecba0e949754d487637c30e8aae0a430212c02 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8163621 Reviewed-by: Adem Derinel <[email protected]> Commit-Queue: Friedrich Hauser <[email protected]> Cr-Commit-Position: refs/heads/main@{#1669428} --- diff --git a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.cc b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.cc index 1e874b6..7cc957d4 100644 --- a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.cc +++ b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.cc @@ -228,12 +228,13 @@ std::move(action_completed).Run(); } -GURL TouchToFillPasswordManagerCredentialDelegate::GetFrameUrl() { +GURL TouchToFillPasswordManagerCredentialDelegate::GetFrameUrl() const { CHECK(filler_); return filler_->GetFrameUrl(); } -url::Origin TouchToFillPasswordManagerCredentialDelegate::GetFrameOrigin() { +url::Origin TouchToFillPasswordManagerCredentialDelegate::GetFrameOrigin() + const { CHECK(filler_); return filler_->GetFrameOrigin(); } diff --git a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.h b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.h index c820880..245a2af3 100644 --- a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.h +++ b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_credential_delegate.h @@ -126,8 +126,8 @@ void OnHybridSignInSelected(base::OnceClosure action_completed) override; void OnDismiss(base::OnceClosure action_completed) override; void OnCredManDismissed(base::OnceClosure action_completed) override; - GURL GetFrameUrl() override; - url::Origin GetFrameOrigin() override; + GURL GetFrameUrl() const override; + url::Origin GetFrameOrigin() const override; bool ShouldShowTouchToFill() override; bool ShouldTriggerSubmission() override; bool ShouldShowHybridOption() override; diff --git a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_delegate.h b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_delegate.h index 7aff6db..11fd5cc 100644 --- a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_delegate.h +++ b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_delegate.h @@ -64,11 +64,11 @@ // Gets the last committed URL for the frame that triggered this sheet to be // created. - virtual GURL GetFrameUrl() = 0; + virtual GURL GetFrameUrl() const = 0; // Gets the last committed origin for the frame that triggered this sheet to // be created. - virtual url::Origin GetFrameOrigin() = 0; + virtual url::Origin GetFrameOrigin() const = 0; // Returns whether TTF is eligible for showing for the currently focused field // (e. g. it should not be triggered for the new password field). diff --git a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.cc b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.cc index b40b068..ab61e7a 100644 --- a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.cc +++ b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.cc @@ -82,16 +82,13 @@ std::move(action_completed).Run(); } -GURL TouchToFillPasswordManagerWebAuthnDelegate::GetFrameUrl() { - return credential_receiver_->web_contents()->GetLastCommittedURL(); +GURL TouchToFillPasswordManagerWebAuthnDelegate::GetFrameUrl() const { + return credential_receiver_ ? credential_receiver_->GetFrameUrl() : GURL(); } -url::Origin TouchToFillPasswordManagerWebAuthnDelegate::GetFrameOrigin() { - return credential_receiver_->web_contents() - ? credential_receiver_->web_contents() - ->GetPrimaryMainFrame() - ->GetLastCommittedOrigin() - : url::Origin(); +url::Origin TouchToFillPasswordManagerWebAuthnDelegate::GetFrameOrigin() const { + return credential_receiver_ ? credential_receiver_->GetFrameOrigin() + : url::Origin(); } bool TouchToFillPasswordManagerWebAuthnDelegate::ShouldShowTouchToFill() { diff --git a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.h b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.h index 56b3cf1..39aada9 100644 --- a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.h +++ b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate.h @@ -68,8 +68,8 @@ void OnHybridSignInSelected(base::OnceClosure action_completed) override; void OnDismiss(base::OnceClosure action_completed) override; void OnCredManDismissed(base::OnceClosure action_completed) override; - GURL GetFrameUrl() override; - url::Origin GetFrameOrigin() override; + GURL GetFrameUrl() const override; + url::Origin GetFrameOrigin() const override; bool ShouldShowTouchToFill() override; bool ShouldTriggerSubmission() override; bool ShouldShowHybridOption() override; diff --git a/chrome/browser/webauthn/android/webauthn_request_delegate_android.cc b/chrome/browser/webauthn/android/webauthn_request_delegate_android.cc index cde7019e..c9ee628b 100644 --- a/chrome/browser/webauthn/android/webauthn_request_delegate_android.cc +++ b/chrome/browser/webauthn/android/webauthn_request_delegate_android.cc @@ -251,3 +251,11 @@ content::WebContents* WebAuthnRequestDelegateAndroid::web_contents() { return content::WebContents::FromRenderFrameHost(&render_frame_host()); } + +GURL WebAuthnRequestDelegateAndroid::GetFrameUrl() const { + return render_frame_host().GetLastCommittedURL(); +} + +url::Origin WebAuthnRequestDelegateAndroid::GetFrameOrigin() const { + return render_frame_host().GetLastCommittedOrigin(); +} diff --git a/chrome/browser/webauthn/android/webauthn_request_delegate_android.h b/chrome/browser/webauthn/android/webauthn_request_delegate_android.h index 0b067c6..ee9392d 100644 --- a/chrome/browser/webauthn/android/webauthn_request_delegate_android.h +++ b/chrome/browser/webauthn/android/webauthn_request_delegate_android.h @@ -80,6 +80,8 @@ void OnCredentialSelectionDeclined() override; void OnHybridSignInSelected() override; content::WebContents* web_contents() override; + GURL GetFrameUrl() const override; + url::Origin GetFrameOrigin() const override; // Returns a delegate associated with the |frame_host|. It creates one if // one does not already exist. diff --git a/chrome/browser/webauthn/touch_to_fill_credential_receiver.h b/chrome/browser/webauthn/touch_to_fill_credential_receiver.h index c963c31..4da9f8d1b 100644 --- a/chrome/browser/webauthn/touch_to_fill_credential_receiver.h +++ b/chrome/browser/webauthn/touch_to_fill_credential_receiver.h @@ -9,6 +9,9 @@ #include <vector> #include "chrome/browser/webauthn/shared_types.h" +#include "url/origin.h" + +class GURL; namespace content { class WebContents; @@ -38,6 +41,12 @@ // Return the WebContents associated with the current WebAuthn request. virtual content::WebContents* web_contents() = 0; + // Return the frame URL associated with the current WebAuthn request. + virtual GURL GetFrameUrl() const = 0; + + // Return the frame origin associated with the current WebAuthn request. + virtual url::Origin GetFrameOrigin() const = 0; + protected: ~TouchToFillCredentialReceiver() = default; }; diff --git a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc index 02d1027..172a8cd 100644 --- a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc +++ b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc @@ -100,6 +100,14 @@ MOCK_METHOD(void, OnHybridSignInSelected, (), (override)); content::WebContents* web_contents() override { return web_contents_; } + GURL GetFrameUrl() const override { + return web_contents_ ? web_contents_->GetLastCommittedURL() : GURL(); + } + url::Origin GetFrameOrigin() const override { + return web_contents_ + ? web_contents_->GetPrimaryMainFrame()->GetLastCommittedOrigin() + : url::Origin(); + } private: raw_ptr<content::WebContents> web_contents_; @@ -373,3 +381,25 @@ std::vector<TouchToFillPasswordManagerView::Credential>, bool)>(webauthn::sorting::SortTouchToFillCredentials))); } + +TEST_F(TouchToFillPasswordManagerWebAuthnDelegateTest, GetFrameUrlAndOrigin) { + auto delegate = MakeTouchToFillPasswordManagerControllerDelegate( + /*should_show_hybrid_option=*/false, + /*is_immediate=*/true, + /*sorting_callback=*/base::NullCallback()); + + EXPECT_EQ(delegate->GetFrameUrl(), GURL(kExampleCom)); + EXPECT_EQ(delegate->GetFrameOrigin(), url::Origin::Create(GURL(kExampleCom))); +} + +TEST_F(TouchToFillPasswordManagerWebAuthnDelegateTest, + GetFrameUrlAndOriginNullReceiver) { + TouchToFillPasswordManagerWebAuthnDelegate delegate( + /*receiver=*/nullptr, + /*sort_credentials_callback=*/base::NullCallback(), + /*should_show_hybrid_option=*/false, + /*is_immediate=*/true); + + EXPECT_EQ(delegate.GetFrameUrl(), GURL()); + EXPECT_TRUE(delegate.GetFrameOrigin().opaque()); +}
Regression Test / PoC
diff --git a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc
index 02d1027..172a8cd 100644
--- a/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc
+++ b/chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_webauthn_delegate_unittest.cc
@@ -100,6 +100,14 @@
MOCK_METHOD(void, OnHybridSignInSelected, (), (override));
content::WebContents* web_contents() override { return web_contents_; }
+ GURL GetFrameUrl() const override {
+ return web_contents_ ? web_contents_->GetLastCommittedURL() : GURL();
+ }
+ url::Origin GetFrameOrigin() const override {
+ return web_contents_
+ ? web_contents_->GetPrimaryMainFrame()->GetLastCommittedOrigin()
+ : url::Origin();
+ }
private:
raw_ptr<content::WebContents> web_contents_;
@@ -373,3 +381,25 @@
std::vector<TouchToFillPasswordManagerView::Credential>,
bool)>(webauthn::sorting::SortTouchToFillCredentials)));
}
+
+TEST_F(TouchToFillPasswordManagerWebAuthnDelegateTest, GetFrameUrlAndOrigin) {
+ auto delegate = MakeTouchToFillPasswordManagerControllerDelegate(
+ /*should_show_hybrid_option=*/false,
+ /*is_immediate=*/true,
+ /*sorting_callback=*/base::NullCallback());
+
+ EXPECT_EQ(delegate->GetFrameUrl(), GURL(kExampleCom));
+ EXPECT_EQ(delegate->GetFrameOrigin(), url::Origin::Create(GURL(kExampleCom)));
+}
+
+TEST_F(TouchToFillPasswordManagerWebAuthnDelegateTest,
+ GetFrameUrlAndOriginNullReceiver) {
+ TouchToFillPasswordManagerWebAuthnDelegate delegate(
+ /*receiver=*/nullptr,
+ /*sort_credentials_callback=*/base::NullCallback(),
+ /*should_show_hybrid_option=*/false,
+ /*is_immediate=*/true);
+
+ EXPECT_EQ(delegate.GetFrameUrl(), GURL());
+ EXPECT_TRUE(delegate.GetFrameOrigin().opaque());
+}