Medium watch CVSS 4.3 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Payments
DescriptionInformation leak in Payments
ComponentPayments
Sub-componentAutofill
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker502488051
Fix commitab229fbda722 (chromium/src) +49/-33
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-14 (per vendor advisory)
Fix landed2026-07-23 (public git)
Disclosed2026-08-25
Patch gap33 days — fix public in source before the advisory

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 FormFieldData carries the origin() 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()`
AutofillClient accessors 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.

Key insight
Affiliation was checked against the top-level page, but the data was filled into whichever frame held the focused field. The fix also requires that field’s origin to equal the main-frame origin.

Attack Path

  1. 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.
  2. 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.
  3. 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.
  4. 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

A cross-origin embedded frame could obtain a user’s stored loyalty-card identifiers that were meant only for the affiliated merchant’s own page. This is an information leak through the browser-process Autofill logic into a web renderer. It requires the loyalty-cards Autofill feature to have data for an affiliated merchant, the attacker frame to be embedded on that merchant’s page, and the user to focus the field and accept a suggestion. On Android the TTF path is affected, and the generic suggestion generator is affected on all platforms that use it. The advisory describes it only as an information leak in Payments, with no in-the-wild status.

Changed Functions

FunctionChangeNotes
if
chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl.cc
modified
if
components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc
modified
TEST_F
components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc
modified

Files Changed

  • components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator_unittest.cc
  • components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator.cc
  • chrome/browser/touch_to_fill/autofill/android/touch_to_fill_payment_method_delegate_android_impl.cc
  • chrome/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 anchor
    Search 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’s origin().
  • Parallel surfaces for the same data
    Wherever 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 checks
    Verify 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.
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, [&current_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(),
Loading diff…

Regression Test / PoC

shipped with the fix
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(),
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • components/autofill/core/browser/suggestions/valuables/valuable_suggestion_generator.cc
  • chrome/browser/ui/autofill/payments/chrome_payments_autofill_client.cc
  • components/autofill/core/browser/foundations/autofill_driver_router.cc
  • components/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.

  1. A victim visits a legitimate merchant site (https://merchant.com) for which they have a saved Google Wallet loyalty card.
  2. The merchant site embeds a malicious or compromised cross-origin iframe (https://attacker.example).
  3. The attacker’s iframe contains an input field crafted to match loyalty card Autofill heuristics (e.g., <input name="loyalty_number">).
  4. The user clicks or taps on this input field within the iframe.
  5. Chrome receives the Autofill request from the attacker.example subframe but incorrectly evaluates affiliation using the merchant.com main frame URL.
  6. Chrome displays a trusted UI (like the Android Touch-to-Fill bottom sheet) offering the user’s merchant.com loyalty card.
  7. The user, trusting the UI and context, selects the card.
  8. Chrome routes the single-field fill action directly to the attacker.example driver without secondary security checks, filling the input field with the cleartext loyalty card number.
  9. Malicious JavaScript in the iframe reads the field value and exfiltrates the data.

Suggested Fix

  1. Correct Suggestion Scoping: Update LoyaltyCardSuggestionGenerator::GenerateSuggestions and TouchToFillDelegateAndroidImpl::DryRunForAffiliatedLoyaltyCard to evaluate affiliation against the trigger_field.origin() instead of GetLastCommittedPrimaryMainFrameURL(). If a loyalty card is not explicitly affiliated with the subframe’s origin, it should not be suggested.
  2. Enforce Single-Field Security: Implement cross-origin security checks in the single-field filling path (either in FormFiller::FillOrPreviewField or AutofillDriverRouter::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.

View on issue tracker
Links in the report