CVE-2026-79095
Overview
Background
- Loyalty cards (Autofill "valuables")
- stored membership or loyalty IDs that Autofill offers on pages whose domain is affiliated with the card’s merchant, decided by
LoyaltyCard::GetAffiliationCategory(url) == AffiliationCategory::kAffiliated. - Touch To Fill (TTF) for payment methods
- the Android bottom-sheet surface (
TouchToFillPaymentMethodDelegateAndroidImpl) that can show loyalty cards for a focused field instead of a dropdown. - Trigger/query field origin
- each
FormFieldDatacarries theorigin()of the frame that contains the field, which differs from the main frame’s origin when the form is inside a cross-origin iframe. - `GetLastCommittedPrimaryMainFrameURL()` / `GetLastCommittedPrimaryMainFrameOrigin()`
AutofillClientaccessors that return the URL or origin of the tab’s top-level document, not of the frame that triggered Autofill.
Root Cause Analysis
Both loyalty-card entry points decided which cards to offer by checking affiliation only against the primary main frame URL (GetLastCommittedPrimaryMainFrameURL()). They ignored which frame the focused field actually lives in. A field in a cross-origin iframe embedded in an affiliated merchant’s page therefore passed the affiliation check, and the user’s loyalty cards for that merchant were offered and, if chosen, filled into a frame that does not belong to the merchant. This breaks the rule that a valuable tied to a merchant domain is only exposed to that merchant’s own document.
The fix tightens both predicates. A card now counts as a match only if it is affiliated with the main-frame URL and GetLastCommittedPrimaryMainFrameOrigin() equals the trigger field’s origin() (query_field_.origin() in the TTF delegate, trigger_field.origin() in the suggestion generator). Fields in frames whose origin differs from the top-level origin no longer get loyalty-card suggestions. The test helper set_last_committed_primary_main_frame_url now sets field origins as well, so existing positive tests keep passing under the stricter rule.
Attack Path
- Embed in a merchant page An attacker controls a cross-origin iframe (e.g. a third-party widget or ad) on a page whose main-frame URL is affiliated with one of the user’s stored loyalty cards.
- Present a loyalty-style field
The iframe contains a form field that Autofill classifies as a loyalty-card field (e.g.
LOYALTY_MEMBERSHIP_ID) and that the user focuses. - Suggestions offered across origins Before the fix, the main-frame affiliation check passed, so the valuable suggestion generator or the Android TTF delegate offered the merchant’s loyalty cards for the iframe field.
- Card data reaches the iframe If the user accepts the suggestion, the loyalty-card value is filled into the attacker’s cross-origin frame, where its script can read it (inferred; the patch shows only the eligibility check).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl.cc |
modified | |
ifcomponents/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc |
modified | |
TEST_Fcomponents/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc |
modified |
Files Changed
components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cccomponents/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator.ccchrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl.ccchrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc
Audit Directions
- Main-frame URL used as the trust anchorSearch Autofill, Payments and Password Manager code for decisions based on
GetLastCommittedPrimaryMainFrameURL()/Origin()(affiliation, secure-context, merchant allowlists) that do not also check the triggering field’s or frame’sorigin(). - Parallel surfaces for the same dataWherever one data type is surfaced through several UIs (dropdown, Touch To Fill, keyboard accessory, Autofill AI, IPH prompts), confirm the cross-frame origin check is applied in every path, not just one.
- Fill-time vs suggest-time checksVerify that origin restrictions are enforced again at fill time, so a frame that changes origin or navigates between suggestion and acceptance cannot receive the value.
Patch
From ab229fbda7229dbdf35293cd2db938961eae46e7 Mon Sep 17 00:00:00 2001 From: Christoph Schwering <[email protected]> Date: Thu, 23 Jul 2026 03:58:14 -0700 Subject: [PATCH] [Autofill] Check trigger field origin for loyalty card association Bug: 502488051 Change-Id: I46980233b26bfc4e4d0735ca2f70a031b5d6e48c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8126509 Reviewed-by: Norge Vizcay <[email protected]> Commit-Queue: Christoph Schwering <[email protected]> Cr-Commit-Position: refs/heads/main@{#1666982} --- diff --git a/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl.cc b/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl.cc index 23ab4d9..e86a6273 100644 --- a/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl.cc +++ b/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl.cc @@ -234,13 +234,13 @@ // Only show the TTF surface if any loyalty card have a matching merchant // domain. - const GURL& current_domain = - manager_->client().GetLastCommittedPrimaryMainFrameURL(); - if (std::ranges::any_of( - loyalty_cards, [¤t_domain](const LoyaltyCard& loyalty_card) { - return loyalty_card.GetAffiliationCategory(current_domain) == - LoyaltyCard::AffiliationCategory::kAffiliated; - })) { + if (std::ranges::any_of(loyalty_cards, [&](const LoyaltyCard& loyalty_card) { + return loyalty_card.GetAffiliationCategory( + manager_->client().GetLastCommittedPrimaryMainFrameURL()) == + LoyaltyCard::AffiliationCategory::kAffiliated && + manager_->client().GetLastCommittedPrimaryMainFrameOrigin() == + query_field_.origin(); + })) { return DryRunResult(TriggerOutcome::kShown, loyalty_cards); } return DryRunResult(TriggerOutcome::kNoValidPaymentMethods, {}); diff --git a/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator.cc b/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator.cc index ed33fe12..1de9ba2 100644 --- a/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator.cc +++ b/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator.cc @@ -326,7 +326,9 @@ all_loyalty_cards, [&](const LoyaltyCard& card) { return card.GetAffiliationCategory( client.GetLastCommittedPrimaryMainFrameURL()) == - LoyaltyCard::AffiliationCategory::kAffiliated; + LoyaltyCard::AffiliationCategory::kAffiliated && + client.GetLastCommittedPrimaryMainFrameOrigin() == + trigger_field.origin(); }); base::span<const LoyaltyCard> affiliated_cards = diff --git a/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc b/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc index b37c74c8..0869edd 100644 --- a/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc +++ b/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc @@ -43,6 +43,7 @@ #include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" #include "url/gurl.h" +#include "url/origin.h" namespace autofill { namespace { @@ -274,6 +275,13 @@ }); } + void set_last_committed_primary_main_frame_url(const GURL& url) { + autofill_client().set_last_committed_primary_main_frame_url(url); + for (auto& field : test_api(form_).fields()) { + field.set_origin(url::Origin::Create(url)); + } + } + // Helper method to add the given `card` and create a card form. void ConfigureForCreditCards(const CreditCard& card) { form_ = test::CreateTestCreditCardFormData(/*is_https=*/true, @@ -307,8 +315,7 @@ form_ = test::CreateTestLoyaltyCardFormData(); test_api(form_).field(0).set_is_focusable(true); // The current URL matches the loyalty card merchant domain. - autofill_client().set_last_committed_primary_main_frame_url( - GURL("https://domain.example")); + set_last_committed_primary_main_frame_url(GURL("https://domain.example")); } void OnFormsSeen() { @@ -822,8 +829,7 @@ TEST_F(TouchToFillPaymentMethodDelegateAndroidImplCreditCardUnitTest, TryToShowTouchToFillFailsIfClientIsNotSecure) { // Simulate non-secure client. - autofill_client().set_last_committed_primary_main_frame_url( - GURL("http://example.test")); + set_last_committed_primary_main_frame_url(GURL("http://example.test")); ASSERT_FALSE(touch_to_fill_delegate_->IsShowingTouchToFill()); @@ -1431,8 +1437,7 @@ LoyaltyCard card2 = test::CreateLoyaltyCard2(); std::vector<LoyaltyCard> loyalty_cards{card2, card1}; // Makes sure there is at least one affiliated card available. - autofill_client().set_last_committed_primary_main_frame_url( - card1.merchant_domains()[0]); + set_last_committed_primary_main_frame_url(card1.merchant_domains()[0]); test_api(*autofill_client().GetValuablesDataManager()) .SetLoyaltyCards(loyalty_cards); @@ -1445,7 +1450,7 @@ TEST_F(TouchToFillPaymentMethodDelegateAndroidImplLoyaltyCardUnitTest, TryToShowTouchToFillFailsIfNoMatchingDomains) { - autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://non-matching.domain")); std::vector<LoyaltyCard> loyalty_cards{test::CreateLoyaltyCard()}; test_api(*autofill_client().GetValuablesDataManager()) @@ -1551,8 +1556,7 @@ form_ = test::CreateTestEmailOrLoyaltyCardFormData(); test_api(form_).field(0).set_is_focusable(true); // The current URL matches the loyalty card merchant domain. - autofill_client().set_last_committed_primary_main_frame_url( - GURL("https://domain.example")); + set_last_committed_primary_main_frame_url(GURL("https://domain.example")); } }; @@ -1564,8 +1568,7 @@ LoyaltyCard card2 = test::CreateLoyaltyCard2(); std::vector<LoyaltyCard> loyalty_cards{card2, card1}; // Makes sure there is at least one affiliated card available. - autofill_client().set_last_committed_primary_main_frame_url( - card1.merchant_domains()[0]); + set_last_committed_primary_main_frame_url(card1.merchant_domains()[0]); test_api(*autofill_client().GetValuablesDataManager()) .SetLoyaltyCards(loyalty_cards); diff --git a/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc b/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc index 5416097..e00e81dd 100644 --- a/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc +++ b/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc @@ -27,6 +27,8 @@ #include "testing/gtest/include/gtest/gtest.h" #include "ui/base/l10n/l10n_util.h" #include "ui/gfx/image/image_unittest_util.h" +#include "url/gurl.h" +#include "url/origin.h" namespace autofill { @@ -122,6 +124,15 @@ test_api(*form_structure_).SetFieldTypes({LOYALTY_MEMBERSHIP_ID}); } + void set_last_committed_primary_main_frame_url(const GURL& url) { + test_autofill_client().set_last_committed_primary_main_frame_url(url); + if (form_structure_) { + for (const auto& field : form_structure_->fields()) { + field->set_origin(url::Origin::Create(url)); + } + } + } + TestAutofillClient& test_autofill_client() { return autofill_client_; } AutofillClient& client() { return autofill_client_; } FormStructure& form() { return *form_structure_; } @@ -142,7 +153,7 @@ }; TEST_F(ValuableSuggestionGeneratorTest, NoMatchingDomain) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://not-existing-domain.example/test")); EXPECT_THAT(GetSuggestionsForLoyaltyCards( form().ToFormData(), &form(), field(), &field(), @@ -151,7 +162,7 @@ } TEST_F(ValuableSuggestionGeneratorTest, NoMatchingDomainAndFieldAutofilled) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://not-existing-domain.example/test")); field().AddFieldModifier(FieldModifier::kAutofill); EXPECT_THAT(GetSuggestionsForLoyaltyCards( @@ -161,7 +172,7 @@ } TEST_F(ValuableSuggestionGeneratorTest, WithMatchingDomain) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://domain2.example/test")); std::vector<Suggestion> suggestions_with_matching_domain = GetSuggestionsForLoyaltyCards(form().ToFormData(), &form(), field(), @@ -201,7 +212,7 @@ } TEST_F(ValuableSuggestionGeneratorTest, WithMatchingDomainAndFieldAutofilled) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://domain2.example/test")); field_data().set_is_autofilled_according_to_renderer(true); std::vector<Suggestion> suggestions_with_matching_domain = @@ -243,7 +254,7 @@ } TEST_F(ValuableSuggestionGeneratorTest, AllMatchDomain) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://common-domain.example/test")); EXPECT_THAT( GetSuggestionsForLoyaltyCards(form().ToFormData(), &form(), field(), @@ -275,7 +286,7 @@ /*use_date=*/{}, /*use_count=*/0)); valuables_data_manager().CacheImage(program_logo, fake_image); test_api(valuables_data_manager()).NotifyObservers(); - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://domain1.example/test")); std::vector<Suggestion> suggestions = GetSuggestionsForLoyaltyCards( @@ -539,7 +550,7 @@ raw_ptr<const base::Feature> kIphFeature = &feature_engagement::kIPHAutofillEnableLoyaltyCardsFeature; - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://domain1.example/test")); EXPECT_THAT(GetSuggestionsForLoyaltyCards( form().ToFormData(), &form(), field(), &field(), @@ -567,7 +578,7 @@ raw_ptr<const base::Feature> kIphFeature = &feature_engagement::kIPHAutofillAiValuablesFeature; - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://domain1.example/test")); EXPECT_THAT(GetSuggestionsForLoyaltyCards( form().ToFormData(), &form(), field(), &field(), @@ -579,7 +590,7 @@ // Checks that all loyalty cards are returned as suggestion data, and // used for generating suggestions. TEST_F(ValuableSuggestionGeneratorTest, GeneratesLoyaltyCardSuggestions) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://common-domain.example/test")); base::MockCallback< @@ -616,7 +627,7 @@ TEST_F( ValuableSuggestionGeneratorWithNonAffiliationSupportTest, ValuableSuggestionGeneratorWithNonAffiliationSupportTest_NoMatchingDomain_NoPasswordForm) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://not-existing-domain.example/test")); std::vector<Suggestion> suggestions = GetSuggestionsForLoyaltyCards( form().ToFormData(), &form(), field(), &field(), @@ -648,7 +659,7 @@ TEST_F( ValuableSuggestionGeneratorWithNonAffiliationSupportTest, ValuableSuggestionGeneratorWithNonAffiliationSupportTest_WithMatchingDomain) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://domain2.example/test")); std::vector<Suggestion> suggestions_with_matching_domain = GetSuggestionsForLoyaltyCards(form().ToFormData(), &form(), field(), @@ -698,7 +709,7 @@ TEST_F( ValuableSuggestionGeneratorWithNonAffiliationSupportTest, ValuableSuggestionGeneratorWithNonAffiliationSupportTest_AllMatchDomain) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://common-domain.example/test")); EXPECT_THAT( GetSuggestionsForLoyaltyCards(form().ToFormData(), &form(), field(), @@ -717,7 +728,7 @@ TEST_F(ValuableSuggestionGeneratorWithNonAffiliationSupportTest, WithMatchingDomain_OnPasswordForm) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://domain2.example/test")); std::vector<Suggestion> suggestions_with_matching_domain = GetSuggestionsForLoyaltyCards( @@ -768,7 +779,7 @@ TEST_F(ValuableSuggestionGeneratorWithNonAffiliationSupportTest, NoMatchingDomain_OnPasswordForm) { - test_autofill_client().set_last_committed_primary_main_frame_url( + set_last_committed_primary_main_frame_url( GURL("https://not-existing-domain.example/test")); EXPECT_THAT(GetSuggestionsForLoyaltyCards( form().ToFormData(), &form(), field(), &field(),
Regression Test / PoC
diff --git a/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc b/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc
index b37c74c8..0869edd 100644
--- a/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc
+++ b/chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl_unittest.cc
@@ -43,6 +43,7 @@
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
+#include "url/origin.h"
namespace autofill {
namespace {
@@ -274,6 +275,13 @@
});
}
+ void set_last_committed_primary_main_frame_url(const GURL& url) {
+ autofill_client().set_last_committed_primary_main_frame_url(url);
+ for (auto& field : test_api(form_).fields()) {
+ field.set_origin(url::Origin::Create(url));
+ }
+ }
+
// Helper method to add the given `card` and create a card form.
void ConfigureForCreditCards(const CreditCard& card) {
form_ = test::CreateTestCreditCardFormData(/*is_https=*/true,
@@ -307,8 +315,7 @@
form_ = test::CreateTestLoyaltyCardFormData();
test_api(form_).field(0).set_is_focusable(true);
// The current URL matches the loyalty card merchant domain.
- autofill_client().set_last_committed_primary_main_frame_url(
- GURL("https://domain.example"));
+ set_last_committed_primary_main_frame_url(GURL("https://domain.example"));
}
void OnFormsSeen() {
@@ -822,8 +829,7 @@
TEST_F(TouchToFillPaymentMethodDelegateAndroidImplCreditCardUnitTest,
TryToShowTouchToFillFailsIfClientIsNotSecure) {
// Simulate non-secure client.
- autofill_client().set_last_committed_primary_main_frame_url(
- GURL("http://example.test"));
+ set_last_committed_primary_main_frame_url(GURL("http://example.test"));
ASSERT_FALSE(touch_to_fill_delegate_->IsShowingTouchToFill());
@@ -1431,8 +1437,7 @@
LoyaltyCard card2 = test::CreateLoyaltyCard2();
std::vector<LoyaltyCard> loyalty_cards{card2, card1};
// Makes sure there is at least one affiliated card available.
- autofill_client().set_last_committed_primary_main_frame_url(
- card1.merchant_domains()[0]);
+ set_last_committed_primary_main_frame_url(card1.merchant_domains()[0]);
test_api(*autofill_client().GetValuablesDataManager())
.SetLoyaltyCards(loyalty_cards);
@@ -1445,7 +1450,7 @@
TEST_F(TouchToFillPaymentMethodDelegateAndroidImplLoyaltyCardUnitTest,
TryToShowTouchToFillFailsIfNoMatchingDomains) {
- autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://non-matching.domain"));
std::vector<LoyaltyCard> loyalty_cards{test::CreateLoyaltyCard()};
test_api(*autofill_client().GetValuablesDataManager())
@@ -1551,8 +1556,7 @@
form_ = test::CreateTestEmailOrLoyaltyCardFormData();
test_api(form_).field(0).set_is_focusable(true);
// The current URL matches the loyalty card merchant domain.
- autofill_client().set_last_committed_primary_main_frame_url(
- GURL("https://domain.example"));
+ set_last_committed_primary_main_frame_url(GURL("https://domain.example"));
}
};
@@ -1564,8 +1568,7 @@
LoyaltyCard card2 = test::CreateLoyaltyCard2();
std::vector<LoyaltyCard> loyalty_cards{card2, card1};
// Makes sure there is at least one affiliated card available.
- autofill_client().set_last_committed_primary_main_frame_url(
- card1.merchant_domains()[0]);
+ set_last_committed_primary_main_frame_url(card1.merchant_domains()[0]);
test_api(*autofill_client().GetValuablesDataManager())
.SetLoyaltyCards(loyalty_cards);
diff --git a/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc b/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc
index 5416097..e00e81dd 100644
--- a/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc
+++ b/components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc
@@ -27,6 +27,8 @@
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/gfx/image/image_unittest_util.h"
+#include "url/gurl.h"
+#include "url/origin.h"
namespace autofill {
@@ -122,6 +124,15 @@
test_api(*form_structure_).SetFieldTypes({LOYALTY_MEMBERSHIP_ID});
}
+ void set_last_committed_primary_main_frame_url(const GURL& url) {
+ test_autofill_client().set_last_committed_primary_main_frame_url(url);
+ if (form_structure_) {
+ for (const auto& field : form_structure_->fields()) {
+ field->set_origin(url::Origin::Create(url));
+ }
+ }
+ }
+
TestAutofillClient& test_autofill_client() { return autofill_client_; }
AutofillClient& client() { return autofill_client_; }
FormStructure& form() { return *form_structure_; }
@@ -142,7 +153,7 @@
};
TEST_F(ValuableSuggestionGeneratorTest, NoMatchingDomain) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://not-existing-domain.example/test"));
EXPECT_THAT(GetSuggestionsForLoyaltyCards(
form().ToFormData(), &form(), field(), &field(),
@@ -151,7 +162,7 @@
}
TEST_F(ValuableSuggestionGeneratorTest, NoMatchingDomainAndFieldAutofilled) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://not-existing-domain.example/test"));
field().AddFieldModifier(FieldModifier::kAutofill);
EXPECT_THAT(GetSuggestionsForLoyaltyCards(
@@ -161,7 +172,7 @@
}
TEST_F(ValuableSuggestionGeneratorTest, WithMatchingDomain) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://domain2.example/test"));
std::vector<Suggestion> suggestions_with_matching_domain =
GetSuggestionsForLoyaltyCards(form().ToFormData(), &form(), field(),
@@ -201,7 +212,7 @@
}
TEST_F(ValuableSuggestionGeneratorTest, WithMatchingDomainAndFieldAutofilled) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://domain2.example/test"));
field_data().set_is_autofilled_according_to_renderer(true);
std::vector<Suggestion> suggestions_with_matching_domain =
@@ -243,7 +254,7 @@
}
TEST_F(ValuableSuggestionGeneratorTest, AllMatchDomain) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://common-domain.example/test"));
EXPECT_THAT(
GetSuggestionsForLoyaltyCards(form().ToFormData(), &form(), field(),
@@ -275,7 +286,7 @@
/*use_date=*/{}, /*use_count=*/0));
valuables_data_manager().CacheImage(program_logo, fake_image);
test_api(valuables_data_manager()).NotifyObservers();
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://domain1.example/test"));
std::vector<Suggestion> suggestions = GetSuggestionsForLoyaltyCards(
@@ -539,7 +550,7 @@
raw_ptr<const base::Feature> kIphFeature =
&feature_engagement::kIPHAutofillEnableLoyaltyCardsFeature;
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://domain1.example/test"));
EXPECT_THAT(GetSuggestionsForLoyaltyCards(
form().ToFormData(), &form(), field(), &field(),
@@ -567,7 +578,7 @@
raw_ptr<const base::Feature> kIphFeature =
&feature_engagement::kIPHAutofillAiValuablesFeature;
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://domain1.example/test"));
EXPECT_THAT(GetSuggestionsForLoyaltyCards(
form().ToFormData(), &form(), field(), &field(),
@@ -579,7 +590,7 @@
// Checks that all loyalty cards are returned as suggestion data, and
// used for generating suggestions.
TEST_F(ValuableSuggestionGeneratorTest, GeneratesLoyaltyCardSuggestions) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://common-domain.example/test"));
base::MockCallback<
@@ -616,7 +627,7 @@
TEST_F(
ValuableSuggestionGeneratorWithNonAffiliationSupportTest,
ValuableSuggestionGeneratorWithNonAffiliationSupportTest_NoMatchingDomain_NoPasswordForm) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://not-existing-domain.example/test"));
std::vector<Suggestion> suggestions = GetSuggestionsForLoyaltyCards(
form().ToFormData(), &form(), field(), &field(),
@@ -648,7 +659,7 @@
TEST_F(
ValuableSuggestionGeneratorWithNonAffiliationSupportTest,
ValuableSuggestionGeneratorWithNonAffiliationSupportTest_WithMatchingDomain) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://domain2.example/test"));
std::vector<Suggestion> suggestions_with_matching_domain =
GetSuggestionsForLoyaltyCards(form().ToFormData(), &form(), field(),
@@ -698,7 +709,7 @@
TEST_F(
ValuableSuggestionGeneratorWithNonAffiliationSupportTest,
ValuableSuggestionGeneratorWithNonAffiliationSupportTest_AllMatchDomain) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://common-domain.example/test"));
EXPECT_THAT(
GetSuggestionsForLoyaltyCards(form().ToFormData(), &form(), field(),
@@ -717,7 +728,7 @@
TEST_F(ValuableSuggestionGeneratorWithNonAffiliationSupportTest,
WithMatchingDomain_OnPasswordForm) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://domain2.example/test"));
std::vector<Suggestion> suggestions_with_matching_domain =
GetSuggestionsForLoyaltyCards(
@@ -768,7 +779,7 @@
TEST_F(ValuableSuggestionGeneratorWithNonAffiliationSupportTest,
NoMatchingDomain_OnPasswordForm) {
- test_autofill_client().set_last_committed_primary_main_frame_url(
+ set_last_committed_primary_main_frame_url(
GURL("https://not-existing-domain.example/test"));
EXPECT_THAT(GetSuggestionsForLoyaltyCards(
form().ToFormData(), &form(), field(), &field(),
Original Bug Report
Potential cross-origin loyalty card data leak via Autofill affiliation check
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 Chrome Security team.
Overview: Chrome’s Autofill functionality for loyalty cards potentially leaks sensitive data to cross-origin subframes. The suggestion generation logic checks affiliation against the main frame’s URL rather than the requesting subframe’s origin. Furthermore, the single-field filling path bypasses cross-origin security checks, allowing an embedded iframe to steal loyalty card numbers.
Affected files:
chrome/browser/touch_to_fill/autofill/android/touch_to_fill_delegate_android_impl.cccomponents/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator.ccchrome/browser/ui/autofill/payments/chrome_payments_autofill_client.cccomponents/autofill/core/browser/foundations/autofill_driver_router.cccomponents/autofill/content/browser/content_autofill_driver.cc
Estimated timestamp from git blame: 2025-10-29
Description
There is a potential vulnerability in Chrome’s Autofill implementation for Google Wallet loyalty cards that allows data to be leaked to cross-origin iframes. The issue stems from two interconnected logic flaws: an incorrect origin check during suggestion generation, and a lack of cross-origin security validation during single-field filling.
1. Incorrect Affiliation Check
When a user interacts with a form field, the browser evaluates available Autofill suggestions. For loyalty cards, this logic is handled in LoyaltyCardSuggestionGenerator::GenerateSuggestions() (Desktop) and TouchToFillDelegateAndroidImpl::DryRunForAffiliatedLoyaltyCard() (Android).
In both instances, Chrome determines if a loyalty card is affiliated with the current context by calling client.GetLastCommittedPrimaryMainFrameURL(). Because this uses the top-level frame’s URL, it ignores the actual origin of the frame requesting the data (trigger_field.origin()). If a user is on https://merchant.com, any embedded cross-origin iframe (e.g., https://attacker.example) will be treated as merchant.com for the purpose of loyalty card affiliation.
2. Bypassed Fill-Time Security Checks
When a user selects the loyalty card from the trusted Chrome UI, the acceptance logic (e.g., AutofillExternalDelegate::DidAcceptSuggestion or TouchToFillDelegateAndroidImpl::LoyaltyCardSuggestionSelected) calls manager_->FillOrPreviewField().
This execution flows through FormFiller::FillOrPreviewField() to AutofillDriverRouter::ApplyFieldAction(). Crucially, ApplyFieldAction is designed for single-field operations and acts purely as a routing mechanism. Unlike multi-field form filling (ApplyFormAction), which rigorously evaluates FormForest::SecurityOptions and the IsSafeToFill cross-origin policy, ApplyFieldAction bypasses these checks entirely. It blindly sends the cleartext loyalty card number via IPC to the renderer belonging to the triggering frame.
Potential Attack Scenario
Note: These are suggested steps based on static code analysis; our tooling agent does not have the ability to run active exploit code to verify them dynamically.
- A victim visits a legitimate merchant site (
https://merchant.com) for which they have a saved Google Wallet loyalty card. - The merchant site embeds a malicious or compromised cross-origin iframe (
https://attacker.example). - The attacker’s iframe contains an input field crafted to match loyalty card Autofill heuristics (e.g.,
<input name="loyalty_number">). - The user clicks or taps on this input field within the iframe.
- Chrome receives the Autofill request from the
attacker.examplesubframe but incorrectly evaluates affiliation using themerchant.commain frame URL. - Chrome displays a trusted UI (like the Android Touch-to-Fill bottom sheet) offering the user’s
merchant.comloyalty card. - The user, trusting the UI and context, selects the card.
- Chrome routes the single-field fill action directly to the
attacker.exampledriver without secondary security checks, filling the input field with the cleartext loyalty card number. - Malicious JavaScript in the iframe reads the field value and exfiltrates the data.
Suggested Fix
- Correct Suggestion Scoping: Update
LoyaltyCardSuggestionGenerator::GenerateSuggestionsandTouchToFillDelegateAndroidImpl::DryRunForAffiliatedLoyaltyCardto evaluate affiliation against thetrigger_field.origin()instead ofGetLastCommittedPrimaryMainFrameURL(). If a loyalty card is not explicitly affiliated with the subframe’s origin, it should not be suggested. - Enforce Single-Field Security: Implement cross-origin security checks in the single-field filling path (either in
FormFiller::FillOrPreviewFieldorAutofillDriverRouter::ApplyFieldAction) to ensure that sensitive data is never dispatched to an origin that does not match the affiliation of the credential being filled.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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.