CVE-2026-17720
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchcomponents/password_manager/core/browser/password_manual_fallback_flow.cc |
modified | |
ifcomponents/password_manager/core/browser/password_manual_fallback_flow.cc |
modified |
Files Changed
components/autofill/core/browser/suggestions/suggestion.hcomponents/password_manager/core/browser/features/password_features.cccomponents/password_manager/core/browser/features/password_features.hcomponents/password_manager/core/browser/password_manual_fallback_flow.cccomponents/password_manager/core/browser/password_manual_fallback_flow_unittest.cc
Patch
From 94b0976121d8dca2faa4ec9b20a30b1d0d9f7a1f Mon Sep 17 00:00:00 2001
From: Timofey Chudakov <tchudakov@google.com>
Date: Mon, 29 Jun 2026 03:02:42 -0700
Subject: [PATCH] [PWM] Do not preview cross domain suggestions in manual fallback.
Recording: http://shortn/_mDGExoMc3P
Fixed: 522545249
Change-Id: Ie525372d9b97db42b697b0189b887b2f0a5b67f3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8006735
Reviewed-by: Jihad Hanna <jihadghanna@google.com>
Reviewed-by: Viktor Semeniuk <vsemeniuk@google.com>
Commit-Queue: Timofey Chudakov <tchudakov@google.com>
Cr-Commit-Position: refs/heads/main@{#1653952}
---
diff --git a/components/autofill/core/browser/suggestions/suggestion.h b/components/autofill/core/browser/suggestions/suggestion.h
index 6d63ef47..6ce9ebf 100644
--- a/components/autofill/core/browser/suggestions/suggestion.h
+++ b/components/autofill/core/browser/suggestions/suggestion.h
@@ -502,6 +502,7 @@
return std::holds_alternative<Guid>(payload) ||
std::holds_alternative<PasswordSuggestionDetails>(payload);
case SuggestionType::kFillPassword:
+ case SuggestionType::kPasswordFieldByFieldFilling:
case SuggestionType::kViewPasswordDetails:
case SuggestionType::kBackupPasswordEntry:
case SuggestionType::kTroubleSigningInEntry:
diff --git a/components/password_manager/core/browser/features/password_features.cc b/components/password_manager/core/browser/features/password_features.cc
index 660174ba..033b436 100644
--- a/components/password_manager/core/browser/features/password_features.cc
+++ b/components/password_manager/core/browser/features/password_features.cc
@@ -101,6 +101,9 @@
BASE_FEATURE(kEnablePasswordManagerMojoApi, base::FEATURE_ENABLED_BY_DEFAULT);
#endif // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
+BASE_FEATURE(kFallbackNoPreviewForCrossDomainCredentials,
+ base::FEATURE_ENABLED_BY_DEFAULT);
+
BASE_FEATURE(kFetchChangePasswordUrlForPasswordChange,
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
// Desktop only since password change is not available on mobile.
diff --git a/components/password_manager/core/browser/features/password_features.h b/components/password_manager/core/browser/features/password_features.h
index ceeef4c..addb5546 100644
--- a/components/password_manager/core/browser/features/password_features.h
+++ b/components/password_manager/core/browser/features/password_features.h
@@ -107,6 +107,10 @@
BASE_DECLARE_FEATURE(kEnablePasswordManagerMojoApi);
#endif // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
+// Cross domain credential data is not previewed by the manual fallback
+// suggestion popup.
+BASE_DECLARE_FEATURE(kFallbackNoPreviewForCrossDomainCredentials);
+
// Fetches change password url if the credential has been identified as leaked.
// Later change password url is used during password change.
BASE_DECLARE_FEATURE(kFetchChangePasswordUrlForPasswordChange);
diff --git a/components/password_manager/core/browser/password_manual_fallback_flow.cc b/components/password_manager/core/browser/password_manual_fallback_flow.cc
index ca19cfb4..ccf44a7 100644
--- a/components/password_manager/core/browser/password_manual_fallback_flow.cc
+++ b/components/password_manager/core/browser/password_manual_fallback_flow.cc
@@ -11,6 +11,7 @@
#include "base/check.h"
#include "base/check_deref.h"
#include "base/containers/to_vector.h"
+#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/metrics/histogram_functions.h"
@@ -21,6 +22,7 @@
#include "components/autofill/core/browser/suggestions/suggestion_hiding_reason.h"
#include "components/autofill/core/browser/ui/popup_open_enums.h"
#include "components/autofill/core/common/aliases.h"
+#include "components/password_manager/core/browser/features/password_features.h"
#include "components/password_manager/core/browser/form_fetcher_impl.h"
#include "components/password_manager/core/browser/form_parsing/form_data_parser.h"
#include "components/password_manager/core/browser/manage_passwords_referrer.h"
@@ -39,9 +41,6 @@
namespace password_manager {
namespace {
-// The length of the password preview label for the cross-domain credentials.
-static constexpr size_t kCrossDomainPasswordPreviewLabelLength = 8;
-
using autofill::Suggestion;
// If `label` was made for an empty username, then return the empty string,
@@ -222,24 +221,42 @@
}
switch (suggestion.type) {
case autofill::SuggestionType::kPasswordEntry: {
+ const auto entry_payload =
+ suggestion.GetPayload<Suggestion::PasswordSuggestionDetails>();
+ if (base::FeatureList::IsEnabled(
+ password_manager::features::
+ kFallbackNoPreviewForCrossDomainCredentials) &&
+ entry_payload.is_cross_domain) {
+ // Do not preview cross-domain credentials to avoid leaking sensitive
+ // data without a consent.
+ return;
+ }
const PasswordForm* form = password_form_cache_->GetPasswordForm(
password_manager_driver_, field_id_);
if (!form) {
return;
}
- const auto payload =
- suggestion.GetPayload<Suggestion::PasswordSuggestionDetails>();
password_manager_driver_->PreviewSuggestionById(
form->username_element_renderer_id,
form->password_element_renderer_id,
GetUsernameFromLabel(suggestion.labels[0][0].value),
- std::u16string(kCrossDomainPasswordPreviewLabelLength, '*'));
+ std::u16string(entry_payload.password.length(), '*'));
break;
}
- case autofill::SuggestionType::kPasswordFieldByFieldFilling:
+ case autofill::SuggestionType::kPasswordFieldByFieldFilling: {
+ if (base::FeatureList::IsEnabled(
+ password_manager::features::
+ kFallbackNoPreviewForCrossDomainCredentials) &&
+ suggestion.GetPayload<Suggestion::PasswordSuggestionDetails>()
+ .is_cross_domain) {
+ // Do not preview cross-domain credentials to avoid leaking sensitive
+ // data without a consent.
+ return;
+ }
password_manager_driver_->PreviewField(field_id_,
suggestion.main_text.value);
break;
+ }
case autofill::SuggestionType::kWebauthnSignInWithAnotherDevice:
if (auto* password_manager_delegate =
password_manager_driver_->GetPasswordManagerDelegate()) {
diff --git a/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc b/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc
index 2c453f4..50b5d35 100644
--- a/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc
+++ b/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc
@@ -623,7 +623,11 @@
EXPECT_CALL(driver(),
PreviewField(field_id, std::u16string(u"username@example.com")));
flow().DidSelectSuggestion(autofill::test::CreateAutofillSuggestion(
- SuggestionType::kPasswordFieldByFieldFilling, u"username@example.com"));
+ SuggestionType::kPasswordFieldByFieldFilling, u"username@example.com",
+ Suggestion::PasswordSuggestionDetails(
+ u"username@example.com", u"password", "https://cross-domain.com/",
+ u"same-domain.com",
+ /*is_cross_domain=*/false)));
}
// Test that username field-by-field suggestion is filled into the correct field
@@ -644,11 +648,15 @@
autofill_client(),
HideSuggestions(SuggestionHidingReason::kAcceptSuggestion,
std::optional(autofill::FillingProduct::kPassword)));
- ShowAndAcceptSuggestion(autofill::test::CreateAutofillSuggestion(
- SuggestionType::kPasswordFieldByFieldFilling,
- u"username@example.com"),
- AutofillSuggestionDelegate::SuggestionMetadata{
- .row = 0, .sub_popup_level = 1});
+ ShowAndAcceptSuggestion(
+ autofill::test::CreateAutofillSuggestion(
+ SuggestionType::kPasswordFieldByFieldFilling, u"username@example.com",
+ Suggestion::PasswordSuggestionDetails(
+ u"username@example.com", u"password", "https://cross-domain.com/",
+ u"same-domain.com",
+ /*is_cross_domain=*/false)),
+ AutofillSuggestionDelegate::SuggestionMetadata{.row = 0,
+ .sub_popup_level = 1});
}
// Test that both username and password are previewed if the suggestion is
@@ -683,8 +691,8 @@
flow().DidSelectSuggestion(suggestion);
}
-// Test that password manual fallback suggestion is previewed without password
-// if the suggestion is cross-domain.
+// Test that password manual fallback suggestion is not previewed if the
+// suggestion is cross-domain.
TEST_F(PasswordManualFallbackFlowTest,
SelectFillFullFormSuggestion_CrossDomain_TriggeredOnAPasswordForm) {
InitializeFlow();
@@ -703,10 +711,7 @@
// Expect that the password is empty in the preview call. The length of the
// label must be fixed and not depend on the password length.
- EXPECT_CALL(driver(), PreviewSuggestionById(form.username_element_renderer_id,
- form.password_element_renderer_id,
- std::u16string(u"username"),
- std::u16string(u"********")));
+ EXPECT_CALL(driver(), PreviewSuggestionById).Times(0);
Suggestion suggestion = autofill::test::CreateAutofillSuggestion(
SuggestionType::kPasswordEntry, u"google.com",
Suggestion::PasswordSuggestionDetails(u"username", u"this_password",
@@ -718,6 +723,38 @@
flow().DidSelectSuggestion(suggestion);
}
+// Test that password manual fallback suggestion is not previewed if the
Regression Test / PoC
diff --git a/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc b/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc
index 2c453f4..50b5d35 100644
--- a/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc
+++ b/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc
@@ -623,7 +623,11 @@
EXPECT_CALL(driver(),
PreviewField(field_id, std::u16string(u"username@example.com")));
flow().DidSelectSuggestion(autofill::test::CreateAutofillSuggestion(
- SuggestionType::kPasswordFieldByFieldFilling, u"username@example.com"));
+ SuggestionType::kPasswordFieldByFieldFilling, u"username@example.com",
+ Suggestion::PasswordSuggestionDetails(
+ u"username@example.com", u"password", "https://cross-domain.com/",
+ u"same-domain.com",
+ /*is_cross_domain=*/false)));
}
// Test that username field-by-field suggestion is filled into the correct field
@@ -644,11 +648,15 @@
autofill_client(),
HideSuggestions(SuggestionHidingReason::kAcceptSuggestion,
std::optional(autofill::FillingProduct::kPassword)));
- ShowAndAcceptSuggestion(autofill::test::CreateAutofillSuggestion(
- SuggestionType::kPasswordFieldByFieldFilling,
- u"username@example.com"),
- AutofillSuggestionDelegate::SuggestionMetadata{
- .row = 0, .sub_popup_level = 1});
+ ShowAndAcceptSuggestion(
+ autofill::test::CreateAutofillSuggestion(
+ SuggestionType::kPasswordFieldByFieldFilling, u"username@example.com",
+ Suggestion::PasswordSuggestionDetails(
+ u"username@example.com", u"password", "https://cross-domain.com/",
+ u"same-domain.com",
+ /*is_cross_domain=*/false)),
+ AutofillSuggestionDelegate::SuggestionMetadata{.row = 0,
+ .sub_popup_level = 1});
}
// Test that both username and password are previewed if the suggestion is
@@ -683,8 +691,8 @@
flow().DidSelectSuggestion(suggestion);
}
-// Test that password manual fallback suggestion is previewed without password
-// if the suggestion is cross-domain.
+// Test that password manual fallback suggestion is not previewed if the
+// suggestion is cross-domain.
TEST_F(PasswordManualFallbackFlowTest,
SelectFillFullFormSuggestion_CrossDomain_TriggeredOnAPasswordForm) {
InitializeFlow();
@@ -703,10 +711,7 @@
// Expect that the password is empty in the preview call. The length of the
// label must be fixed and not depend on the password length.
- EXPECT_CALL(driver(), PreviewSuggestionById(form.username_element_renderer_id,
- form.password_element_renderer_id,
- std::u16string(u"username"),
- std::u16string(u"********")));
+ EXPECT_CALL(driver(), PreviewSuggestionById).Times(0);
Suggestion suggestion = autofill::test::CreateAutofillSuggestion(
SuggestionType::kPasswordEntry, u"google.com",
Suggestion::PasswordSuggestionDetails(u"username", u"this_password",
@@ -718,6 +723,38 @@
flow().DidSelectSuggestion(suggestion);
}
+// Test that password manual fallback suggestion is not previewed if the
+// suggestion is cross-domain.
+TEST_F(PasswordManualFallbackFlowTest,
+ SelectFieldByFieldSuggestion_CrossDomain_TriggeredOnAPasswordForm) {
+ InitializeFlow();
+ ProcessPasswordStoreUpdates();
+
+ PasswordForm form;
+ form.username_element_renderer_id = MakeFieldRendererId();
+ form.password_element_renderer_id = MakeFieldRendererId();
+ // Simulate that the field is/isn't classified as target filling password.
+ EXPECT_CALL(password_form_cache(),
+ GetPasswordForm(_, form.username_element_renderer_id))
+ .WillRepeatedly(Return(&form));
+
+ flow().RunFlow(form.username_element_renderer_id, gfx::RectF{},
+ TextDirection::LEFT_TO_RIGHT);
+
+ // Expect that the password is empty in the preview call. The length of the
+ // label must be fixed and not depend on the password length.
+ EXPECT_CALL(driver(), PreviewField).Times(0);
+ Suggestion suggestion = autofill::test::CreateAutofillSuggestion(
+ SuggestionType::kPasswordFieldByFieldFilling, u"google.com",
+ Suggestion::PasswordSuggestionDetails(u"username", u"this_password",
+ "https://cross-domain.com/",
+ u"cross-domain.com",
+ /*is_cross_domain=*/true));
+ suggestion.labels = {{Suggestion::Text(u"username")}};
+ suggestion.acceptability = Suggestion::Acceptability::kAcceptable;
+ flow().DidSelectSuggestion(suggestion);
+}
+
// Test that only password field is previewed if the credential doesn't have
// a username saved for it.
TEST_F(PasswordManualFallbackFlowTest,
@@ -1566,7 +1603,11 @@
base::HistogramTester histograms;
autofill::Suggestion suggestion = autofill::test::CreateAutofillSuggestion(
- SuggestionType::kPasswordFieldByFieldFilling, u"password");
+ SuggestionType::kPasswordFieldByFieldFilling, u"password",
+ Suggestion::PasswordSuggestionDetails(u"username", u"password",
+ "https://cross-domain.com/",
+ u"same-domain.com",
+ /*is_cross_domain=*/false));
if (SuggestionAccepted()) {
ShowAndAcceptSuggestion(
suggestion,
diff --git a/components/password_manager/core/browser/password_suggestion_generator_unittest.cc b/components/password_manager/core/browser/password_suggestion_generator_unittest.cc
index 71393adf..a99a43d 100644
--- a/components/password_manager/core/browser/password_suggestion_generator_unittest.cc
+++ b/components/password_manager/core/browser/password_suggestion_generator_unittest.cc
@@ -1237,7 +1237,11 @@
suggestions[0].children,
ElementsAre(
EqualsSuggestion(SuggestionType::kPasswordFieldByFieldFilling,
- u"username@example.com"),
+ u"username@example.com", Suggestion::Icon::kNoIcon,
+ Suggestion::PasswordSuggestionDetails(
+ u"username@example.com", u"password",
+ "https://google.com/", u"google.com",
+ /*is_cross_domain=*/false)),
EqualsSuggestion(
SuggestionType::kFillPassword,
l10n_util::GetStringUTF16(
@@ -1266,7 +1270,11 @@
suggestions[0].children,
ElementsAre(
EqualsSuggestion(SuggestionType::kPasswordFieldByFieldFilling,
- u"username@example.com"),
+ u"username@example.com", Suggestion::Icon::kNoIcon,
+ Suggestion::PasswordSuggestionDetails(
+ u"username@example.com", u"password",
+ "https://google.com/", u"google.com",
+ /*is_cross_domain=*/true)),
EqualsSuggestion(
SuggestionType::kFillPassword,
l10n_util::GetStringUTF16(
Original Bug Report
Potential cross-domain username leak via Password Manual Fallback preview
Flapjack, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A compromised renderer can potentially exfiltrate cross-domain usernames during the Password Manual Fallback preview phase. While passwords are masked in previews, usernames are transmitted in cleartext via Mojo IPC, bypassing Site Isolation protections without explicit user consent.
Affected files:
components/password_manager/core/browser/password_manual_fallback_flow.cc
Estimated timestamp from git blame: 2024-02-27
Summary
A potential vulnerability exists in the Password Manual Fallback flow where a compromised renderer can steal cross-domain usernames without explicit user interaction or consent. Although cross-domain passwords are masked during the preview phase, the cleartext username is still transmitted back to the renderer via Mojo IPC. This allows an attacker to exfiltrate sensitive PII (usernames) across origins, bypassing Site Isolation.
Vulnerability Details
In components/password_manager/core/browser/password_manual_fallback_flow.cc, the DidSelectSuggestion method handles the preview of password suggestions when a user hovers over an item in the autofill popup.
For autofill::SuggestionType::kPasswordEntry:
case autofill::SuggestionType::kPasswordEntry: {
// ...
const auto payload =
suggestion.GetPayload<Suggestion::PasswordSuggestionDetails>();
password_manager_driver_->PreviewSuggestionById(
form->username_element_renderer_id,
form->password_element_renderer_id,
GetUsernameFromLabel(suggestion.labels[0][0].value),
std::u16string(kCrossDomainPasswordPreviewLabelLength, '*'));
break;
}
While the password is masked, GetUsernameFromLabel extracts the cleartext username from the suggestion’s label. Crucially, unlike DidAcceptSuggestion, DidSelectSuggestion lacks any checks for payload.is_cross_domain. Consequently, the cleartext cross-domain username is immediately sent to the renderer via the PreviewSuggestionById Mojo IPC.
Potential Exploitation Path
Note: These are suggested steps based on static analysis; a working proof-of-concept has not been run.
An attacker controlling a renderer process could potentially exploit this by:
- Constructing a dummy password form in the DOM to ensure a valid
PasswordFormexists in the browser’spassword_form_cache_. - Tracking the user’s mouse movements to predict the cursor’s path.
- Sending a spoofed
AskForValuesToFillMojo IPC message to the browser, specifyingtrigger_source = autofill::AutofillSuggestionTriggerSource::kManualFallbackPasswordsfor the dummy field. - Timing the IPC so the resulting ‘All passwords’ fallback popup spawns directly in the path of the user’s moving cursor.
- As the cursor naturally enters a cross-domain suggestion row, it satisfies the
mouse_observed_outside_item_bounds_check inPopupRowView, triggering a valid hover event. PasswordManualFallbackFlow::DidSelectSuggestionprocesses the hover and transmits the cleartext cross-domain username via thePreviewPasswordSuggestionByIdMojo IPC.- The compromised renderer intercepts the IPC or reads the DOM preview to exfiltrate the username.
Proposed Remediation
In PasswordManualFallbackFlow::DidSelectSuggestion, check the is_cross_domain flag on the suggestion payload before transmitting the preview. For cross-domain credentials, the username should either be masked or the preview should be entirely disabled until the user explicitly accepts the suggestion and clears the cross-domain consent dialog.
Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.