Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Autofill
DescriptionInsufficient policy enforcement in Autofill
ComponentAutofill
Bug ClassLogic Error
Tracker504215814
Fix commit978bf23d7ccb (chromium/src) +222/-48
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
ios/chrome/browser/autofill/ui_bundled/manual_fill/card_coordinator.mm
modified
if
ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator.mm
modified

Files Changed

  • ios/chrome/browser/autofill/ui_bundled/BUILD.gn
  • ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client.mm
  • ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm
  • ios/chrome/browser/autofill/ui_bundled/manual_fill/BUILD.gn
  • ios/chrome/browser/autofill/ui_bundled/manual_fill/card_coordinator.mm
  • ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator.mm
  • ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm
From 978bf23d7ccbf446c720ea37ee6f5bd53cec5555 Mon Sep 17 00:00:00 2001
From: YiwenQian <[email protected]>
Date: Mon, 04 May 2026 12:08:28 -0700
Subject: [PATCH] [VCN iOS] Fix cross-origin cache leakage and enforce user gesture for focus

1. Enforced user gesture check indidRegisterFormActivity:inFrame:. Focus
events without a user gesture are now ignored.

2. Added a Clear() method to ManualFillVirtualCardCache and called it in
ManualFillInjectionHandler whenever the focused frame changes. This
prevents cross-frame cache leakage.

Bug: 504215814
Change-Id: Ia178a958ab5754ac9a7e2c6a85853f671a08f53f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7779851
Reviewed-by: Tommy Martino <[email protected]>
Reviewed-by: Qihui Zhao <[email protected]>
Commit-Queue: yiwen qian <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1624858}
---

diff --git a/ios/chrome/browser/autofill/ui_bundled/BUILD.gn b/ios/chrome/browser/autofill/ui_bundled/BUILD.gn
index 2d7f7c63..c335c211 100644
--- a/ios/chrome/browser/autofill/ui_bundled/BUILD.gn
+++ b/ios/chrome/browser/autofill/ui_bundled/BUILD.gn
@@ -47,6 +47,7 @@
     "//ios/chrome/browser/autofill/ui_bundled/cells",
     "//ios/chrome/browser/autofill/ui_bundled/manual_fill",
     "//ios/chrome/browser/autofill/ui_bundled/manual_fill:manual_fill_ui",
+    "//ios/chrome/browser/autofill/ui_bundled/manual_fill:requesters",
     "//ios/chrome/browser/consent_auditor/model",
     "//ios/chrome/browser/device_reauth/model",
     "//ios/chrome/browser/history/model",
@@ -257,6 +258,7 @@
     "//ios/chrome/browser/autofill/ui_bundled/address_editor:constants",
     "//ios/chrome/browser/autofill/ui_bundled/cells",
     "//ios/chrome/browser/autofill/ui_bundled/manual_fill",
+    "//ios/chrome/browser/autofill/ui_bundled/manual_fill:requesters",
     "//ios/chrome/browser/autofill/ui_bundled/util:unit_tests",
     "//ios/chrome/browser/infobars/model",
     "//ios/chrome/browser/infobars/model:public",
diff --git a/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client.mm b/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client.mm
index e5cd8ee..0ce98d0 100644
--- a/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client.mm
+++ b/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client.mm
@@ -274,8 +274,10 @@
     card.set_cvc(options.cvc);
 
     ManualFillVirtualCardCache::CreateForWebState(web_state_);
+    url::Origin origin = ManualFillVirtualCardCache::FromWebState(web_state_)
+                             ->GetUnmaskingOrigin();
     ManualFillVirtualCardCache::FromWebState(web_state_)
-        ->CacheUnmaskedCard(card);
+        ->CacheUnmaskedCard(card, origin);
   }
 }
 
diff --git a/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm b/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm
index bd16e18..df67fb35a 100644
--- a/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm
+++ b/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm
@@ -796,20 +796,26 @@
        OnCardDataAvailable_CachesVirtualCard) {
   // Create a virtual card
   autofill::CreditCard card = autofill::test::GetVirtualCard();
+  card.set_server_id("test_server_id");
+  card.set_record_type(autofill::CreditCard::RecordType::kVirtualCard);
 
   autofill::FilledCardInformationBubbleOptions options;
   options.filled_card = card;
   options.cvc = u"123";
 
+  url::Origin test_origin = url::Origin::Create(GURL("https://example.com"));
+  ManualFillVirtualCardCache::CreateForWebState(web_state_.get());
+  ManualFillVirtualCardCache::FromWebState(web_state_.get())
+      ->SetUnmaskingOrigin(test_origin);
+
   payments_client()->OnCardDataAvailable(options);
 
-  // Verify that the card is in the cache
   ManualFillVirtualCardCache* cache =
       ManualFillVirtualCardCache::FromWebState(web_state_.get());
   ASSERT_TRUE(cache);
 
   const autofill::CreditCard* cached_card =
-      cache->GetUnmaskedCard(card.server_id());
+      cache->GetUnmaskedCard(card.server_id(), test_origin);
   ASSERT_TRUE(cached_card);
   EXPECT_EQ(cached_card->cvc(), u"123");
 }
diff --git a/ios/chrome/browser/autofill/ui_bundled/manual_fill/BUILD.gn b/ios/chrome/browser/autofill/ui_bundled/manual_fill/BUILD.gn
index 7b7a223..2fc6624 100644
--- a/ios/chrome/browser/autofill/ui_bundled/manual_fill/BUILD.gn
+++ b/ios/chrome/browser/autofill/ui_bundled/manual_fill/BUILD.gn
@@ -41,8 +41,6 @@
     "manual_fill_password_coordinator.mm",
     "manual_fill_plus_address_mediator.h",
     "manual_fill_plus_address_mediator.mm",
-    "manual_fill_virtual_card_cache.h",
-    "manual_fill_virtual_card_cache.mm",
     "plus_address_coordinator_delegate.h",
   ]
   deps = [
@@ -290,6 +288,8 @@
     "full_card_request_result_delegate_bridge.h",
     "manual_fill_full_card_requester.h",
     "manual_fill_full_card_requester.mm",
+    "manual_fill_virtual_card_cache.h",
+    "manual_fill_virtual_card_cache.mm",
   ]
   deps = [
     ":manual_fill_constants",
diff --git a/ios/chrome/browser/autofill/ui_bundled/manual_fill/card_coordinator.mm b/ios/chrome/browser/autofill/ui_bundled/manual_fill/card_coordinator.mm
index 4d0c0b5..8a027b8 100644
--- a/ios/chrome/browser/autofill/ui_bundled/manual_fill/card_coordinator.mm
+++ b/ios/chrome/browser/autofill/ui_bundled/manual_fill/card_coordinator.mm
@@ -200,7 +200,8 @@
         ManualFillVirtualCardCache::FromWebState(activeWebState);
     if (cache) {
       const autofill::CreditCard* cachedCard =
-          cache->GetUnmaskedCard(autofillCreditCard->guid());
+          cache->GetUnmaskedCard(autofillCreditCard->guid(),
+                                 [self.injectionHandler activeWebFrameOrigin]);
 
       if (cachedCard) {
         // Cache Hit: Skip network request and fill directly.
@@ -212,6 +213,10 @@
     }
   }
 
+  ManualFillVirtualCardCache::FromWebState(
+      self.browser->GetWebStateList()->GetActiveWebState())
+      ->SetUnmaskingOrigin([self.injectionHandler activeWebFrameOrigin]);
+
   [self.cardRequester requestFullCreditCard:*autofillCreditCard
                      withBaseViewController:self.baseViewController
                                  recordType:card.recordType
diff --git a/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator.mm b/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator.mm
index a82ef01..a5ddb05b 100644
--- a/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator.mm
+++ b/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator.mm
@@ -28,6 +28,7 @@
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_content_injector.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_credit_card+CreditCard.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_credit_card.h"
+#import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_injection_handler.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache.h"
 #import "ios/chrome/browser/menu/ui_bundled/browser_action_factory.h"
 #import "ios/chrome/browser/shared/ui/list_model/list_model.h"
@@ -184,8 +185,12 @@
         ManualFillVirtualCardCache* cache =
             ManualFillVirtualCardCache::FromWebState(_webState.get());
         if (cache) {
-          if (const CreditCard* cachedCard =
-                  cache->GetUnmaskedCard(virtualCard.server_id())) {
+          if (const CreditCard* cachedCard = cache->GetUnmaskedCard(
+                  virtualCard.server_id(),
+                  [self.contentInjector
+                      respondsToSelector:@selector(activeWebFrameOrigin)]
+                      ? [(id)self.contentInjector activeWebFrameOrigin]
+                      : url::Origin())) {
             virtualCard = *cachedCard;
           }
         }
@@ -341,7 +346,10 @@
   if (webState && card.record_type() == CreditCard::RecordType::kVirtualCard) {
     // CreateForWebState ensures the cache exists (lazy initialization).
     ManualFillVirtualCardCache::CreateForWebState(webState);
-    ManualFillVirtualCardCache::FromWebState(webState)->CacheUnmaskedCard(card);
+    url::Origin origin = ManualFillVirtualCardCache::FromWebState(webState)
+                             ->GetUnmaskingOrigin();
+    ManualFillVirtualCardCache::FromWebState(webState)->CacheUnmaskedCard(
+        card, origin);
   }
   // Credit card are not shown as 'Secure'.
   ManualFillCreditCard* manualFillCreditCard = [[ManualFillCreditCard alloc]
diff --git a/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm b/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm
index bb74fc7..73520ea 100644
--- a/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm
+++ b/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm
@@ -17,6 +17,7 @@
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/card_consumer.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/card_list_delegate.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_cell+Testing.h"
+#import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_injection_handler.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache.h"
 #import "ios/chrome/browser/infobars/model/infobar_manager_impl.h"
 #import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
@@ -65,6 +66,34 @@
 
 }  // namespace
 
+// A fake implementation of ManualFillContentInjector for testing.
+@interface FakeContentInjector : NSObject <ManualFillContentInjector>
+@property(nonatomic, assign) url::Origin activeOrigin;
+@end
+
+@implementation FakeContentInjector
+- (url::Origin)activeWebFrameOrigin {
+  return self.activeOrigin;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm b/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm
index bd16e18..df67fb35a 100644
--- a/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm
+++ b/ios/chrome/browser/autofill/ui_bundled/ios_chrome_payments_autofill_client_unittest.mm
@@ -796,20 +796,26 @@
        OnCardDataAvailable_CachesVirtualCard) {
   // Create a virtual card
   autofill::CreditCard card = autofill::test::GetVirtualCard();
+  card.set_server_id("test_server_id");
+  card.set_record_type(autofill::CreditCard::RecordType::kVirtualCard);
 
   autofill::FilledCardInformationBubbleOptions options;
   options.filled_card = card;
   options.cvc = u"123";
 
+  url::Origin test_origin = url::Origin::Create(GURL("https://example.com"));
+  ManualFillVirtualCardCache::CreateForWebState(web_state_.get());
+  ManualFillVirtualCardCache::FromWebState(web_state_.get())
+      ->SetUnmaskingOrigin(test_origin);
+
   payments_client()->OnCardDataAvailable(options);
 
-  // Verify that the card is in the cache
   ManualFillVirtualCardCache* cache =
       ManualFillVirtualCardCache::FromWebState(web_state_.get());
   ASSERT_TRUE(cache);
 
   const autofill::CreditCard* cached_card =
-      cache->GetUnmaskedCard(card.server_id());
+      cache->GetUnmaskedCard(card.server_id(), test_origin);
   ASSERT_TRUE(cached_card);
   EXPECT_EQ(cached_card->cvc(), u"123");
 }
diff --git a/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm b/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm
index bb74fc7..73520ea 100644
--- a/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm
+++ b/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator_unittest.mm
@@ -17,6 +17,7 @@
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/card_consumer.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/card_list_delegate.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_cell+Testing.h"
+#import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_injection_handler.h"
 #import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache.h"
 #import "ios/chrome/browser/infobars/model/infobar_manager_impl.h"
 #import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
@@ -65,6 +66,34 @@
 
 }  // namespace
 
+// A fake implementation of ManualFillContentInjector for testing.
+@interface FakeContentInjector : NSObject <ManualFillContentInjector>
+@property(nonatomic, assign) url::Origin activeOrigin;
+@end
+
+@implementation FakeContentInjector
+- (url::Origin)activeWebFrameOrigin {
+  return self.activeOrigin;
+}
+- (BOOL)canUserInjectInPasswordField:(BOOL)passwordField
+                       requiresHTTPS:(BOOL)requiresHTTPS {
+  return YES;
+}
+- (void)userDidPickContent:(NSString*)content
+             passwordField:(BOOL)passwordField
+             requiresHTTPS:(BOOL)requiresHTTPS {
+}
+- (void)autofillFormWithCredential:(ManualFillCredential*)credential
+                      shouldReauth:(BOOL)shouldReauth {
+}
+- (void)autofillFormWithSuggestion:(FormSuggestion*)formSuggestion
+                           atIndex:(NSInteger)index {
+}
+- (BOOL)isActiveFormAPasswordForm {
+  return NO;
+}
+@end
+
 // Test fixture for testing the ManualFillCardMediator class.
 class ManualFillCardMediatorTest : public PlatformTest {
  protected:
@@ -97,6 +126,7 @@
                                       autofill::test::NextMonth().c_str(),
                                       autofill::test::NextYear().c_str(), "1");
     card.set_guid(guid);
+    card.set_server_id(guid);
     card.set_instrument_id(0);
     card.set_record_type(CreditCard::RecordType::kMaskedServerCard);
     if (enrolled_for_virtual_card) {
@@ -186,6 +216,12 @@
   card.set_server_id("test_server_id");
   card.set_record_type(CreditCard::RecordType::kVirtualCard);
 
+  url::Origin test_origin = url::Origin::Create(GURL("https://example.com"));
+  // Set the origin in the cache before triggering the success callback.
+  ManualFillVirtualCardCache::CreateForWebState(web_state.get());
+  ManualFillVirtualCardCache::FromWebState(web_state.get())
+      ->SetUnmaskingOrigin(test_origin);
+
   // Simulate the request.
   [mediator()
       onFullCardRequestSucceeded:card
@@ -197,7 +233,8 @@
       ManualFillVirtualCardCache::FromWebState(web_state.get());
   ASSERT_TRUE(cache);
 
-  const CreditCard* cached_card = cache->GetUnmaskedCard(card.server_id());
+  const CreditCard* cached_card =
+      cache->GetUnmaskedCard(card.server_id(), test_origin);
   ASSERT_TRUE(cached_card);
   EXPECT_EQ(cached_card->number(), card.number());
 }
@@ -232,7 +269,7 @@
       ManualFillVirtualCardCache::FromWebState(web_state.get());
 
   if (cache) {
-    EXPECT_EQ(nullptr, cache->GetUnmaskedCard(card.server_id()));
+    EXPECT_EQ(nullptr, cache->GetUnmaskedCard(card.server_id(), url::Origin()));
   }
 }
 
@@ -310,9 +347,15 @@
   unmaskedCard.SetRawInfo(autofill::CREDIT_CARD_NUMBER, u"4234567890123456");
   unmaskedCard.set_cvc(u"123");
 
+  // Use FakeContentInjector to return a specific origin.
+  FakeContentInjector* fake_injector = [[FakeContentInjector alloc] init];
+  url::Origin test_origin = url::Origin::Create(GURL("https://example.com"));
+  fake_injector.activeOrigin = test_origin;
+  mediator_.contentInjector = fake_injector;
+
   ManualFillVirtualCardCache::CreateForWebState(web_state.get());
   ManualFillVirtualCardCache::FromWebState(web_state.get())
-      ->CacheUnmaskedCard(unmaskedCard);
+      ->CacheUnmaskedCard(unmaskedCard, test_origin);
 
   auto captured_card_items =
       std::make_shared<NSArray<ManualFillCardItem*>*>(nil);
diff --git a/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache_unittest.mm b/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache_unittest.mm
index 8c8c4ffa..4cdfff01 100644
--- a/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache_unittest.mm
+++ b/ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache_unittest.mm
@@ -6,7 +6,14 @@
 #import "base/strings/sys_string_conversions.h"
 #import "components/autofill/core/browser/data_model/payments/credit_card.h"
 #import "components/autofill/core/browser/test_utils/autofill_test_utils.h"
+#import "components/autofill/ios/form_util/form_activity_observer_bridge.h"
+#import "components/autofill/ios/form_util/form_activity_params.h"
+#import "ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_injection_handler.h"
+#import "ios/chrome/browser/shared/model/web_state_list/test/fake_web_state_list_delegate.h"
+#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
 #import "ios/web/public/test/fakes/fake_navigation_context.h"
+#import "ios/web/public/test/fakes/fake_navigation_manager.h"
+#import "ios/web/public/test/fakes/fake_web_frame.h"
 #import "ios/web/public/test/fakes/fake_web_state.h"
 #import "testing/platform_test.h"
 #import "url/gurl.h"
@@ -31,20 +38,22 @@
   web::FakeWebState web_state_;
 };
 
-// Tests that a card can be cached and retrieved by its GUID.
+// Tests that a card can be cached and retrieved by its server_id.
 TEST_F(ManualFillVirtualCardCacheTest, CacheAndRetrieveCard) {
   CreditCard card = GetVirtualCard();
   card.set_server_id("test_server_id");
   std::string server_id = card.server_id();
 
+  url::Origin origin = url::Origin::Create(GURL("https://example.com"));
   // Initially, cache should be empty.
-  EXPECT_EQ(nullptr, cache()->GetUnmaskedCard(server_id));
+  EXPECT_EQ(nullptr, cache()->GetUnmaskedCard(server_id, origin));
 
   // Cache the card.
-  cache()->CacheUnmaskedCard(card);
+  cache()->CacheUnmaskedCard(card, origin);
 
   // Verify retrieval.
-  const CreditCard* retrieved_card = cache()->GetUnmaskedCard(server_id);
+  const CreditCard* retrieved_card =
+      cache()->GetUnmaskedCard(server_id, origin);
   ASSERT_NE(nullptr, retrieved_card);
   EXPECT_EQ(card.guid(), retrieved_card->guid());
   EXPECT_EQ(card.number(), retrieved_card->number());
@@ -60,11 +69,12 @@
   CreditCard card = GetVirtualCard();
   card.set_server_id("test_server_id");
 
+  url::Origin origin = url::Origin::Create(GURL("https://example.com"));
   // Cache in the main WebState.
-  cache()->CacheUnmaskedCard(card);
+  cache()->CacheUnmaskedCard(card, origin);
 
   // Verify it is NOT available in the other WebState.
-  EXPECT_EQ(nullptr, other_cache->GetUnmaskedCard(card.server_id()));
+  EXPECT_EQ(nullptr, other_cache->GetUnmaskedCard(card.server_id(), origin));
 }
 
 // Tests that the cache is CLEARED when navigating to a new document (e.g. new
@@ -72,21 +82,20 @@
 TEST_F(ManualFillVirtualCardCacheTest, ClearsOnNewDocumentNavigation) {
   CreditCard card = GetVirtualCard();
   card.set_server_id("test_server_id");
-  cache()->CacheUnmaskedCard(card);
-  ASSERT_NE(nullptr, cache()->GetUnmaskedCard(card.server_id()));
+  url::Origin origin = url::Origin::Create(GURL("https://example.com"));
+  cache()->CacheUnmaskedCard(card, origin);
+  ASSERT_NE(nullptr, cache()->GetUnmaskedCard(card.server_id(), origin));
 
   // Simulate a navigation to a new page.
   web::FakeNavigationContext context;
   context.SetIsSameDocument(false);  // New document
 
-  // Trigger the observer method manually (since FakeWebState doesn't
-  // auto-dispatch strictly). Note: in production, WebState dispatches this
-  // automatically. We cast to the observer interface to simulate the event.
+  // Trigger the observer method manually.
   static_cast<web::WebStateObserver*>(cache())->DidFinishNavigation(&web_state_,
                                                                     &context);
 
   // Verify cache is cleared.
-  EXPECT_EQ(nullptr, cache()->GetUnmaskedCard(card.server_id()));
+  EXPECT_EQ(nullptr, cache()->GetUnmaskedCard(card.server_id(), origin));
 }
 
 // Tests that the cache PERSISTS when navigating within the same document (e.g.
@@ -94,8 +103,9 @@
 TEST_F(ManualFillVirtualCardCacheTest, PersistsOnSameDocumentNavigation) {
   CreditCard card = GetVirtualCard();
   card.set_server_id("test_server_id");
-  cache()->CacheUnmaskedCard(card);
-  ASSERT_NE(nullptr, cache()->GetUnmaskedCard(card.server_id()));
+  url::Origin origin = url::Origin::Create(GURL("https://example.com"));
+  cache()->CacheUnmaskedCard(card, origin);
+  ASSERT_NE(nullptr, cache()->GetUnmaskedCard(card.server_id(), origin));
 
   // Simulate a same-document navigation (e.g. #fragment change).
   web::FakeNavigationContext context;
@@ -105,7 +115,25 @@
                                                                     &context);
 
   // Verify cache still exists.
-  EXPECT_NE(nullptr, cache()->GetUnmaskedCard(card.server_id()));
+  EXPECT_NE(nullptr, cache()->GetUnmaskedCard(card.server_id(), origin));
+}
+
+// Tests that the cache is isolated per origin.
+TEST_F(ManualFillVirtualCardCacheTest, OriginIsolated) {
+  CreditCard card = GetVirtualCard();
+  card.set_server_id("test_server_id");
+
+  url::Origin origin_a = url::Origin::Create(GURL("https://a.com"));
+  url::Origin origin_b = url::Origin::Create(GURL("https://b.com"));
+
+  // Cache card for Origin A.
+  cache()->CacheUnmaskedCard(card, origin_a);
+
+  // Verify retrieval with Origin A works.
+  EXPECT_NE(nullptr, cache()->GetUnmaskedCard("test_server_id", origin_a));
+
+  // Verify retrieval with Origin B returns null (isolated).
+  EXPECT_EQ(nullptr, cache()->GetUnmaskedCard("test_server_id", origin_b));
 }
 
 }  // namespace
Loading diff…

Original Bug Report

reported by [email protected]

Potential cross-origin leak of VCNs via iOS Manual Fill Cache & Programmatic Focus

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. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: The iOS Manual Fill feature caches unmasked Virtual Credit Cards per-tab and skips authentication on cache hits. A malicious cross-origin iframe can steal these unmasked details by programmatically calling focus() while the accessory is open, as the injection handler fails to verify if the focus event originated from a user gesture.

Affected files:

  • ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache.mm
  • ios/chrome/browser/autofill/ui_bundled/manual_fill/card_coordinator.mm
  • ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_injection_handler.mm
  • ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_card_mediator.mm
  • ios/chrome/browser/autofill/ui_bundled/manual_fill/manual_fill_virtual_card_cache.h

Estimated timestamp from git blame: 2026-01-29

Background

The iOS Manual Fill feature provides a keyboard accessory that allows users to manually fill payment information into web forms. When a user selects a virtual card (VCN), they undergo authentication to unmask it. To improve UX, a caching mechanism (ManualFillVirtualCardCache) stores the unmasked card so subsequent taps on the same card (e.g., filling Card Number then CVC) skip re-authentication.

Vulnerability Details

A combination of insecure caching and insufficient focus-event validation leads to a potential cross-origin leak of unmasked credit card data.

1. Insecure Virtual Card Cache: ManualFillVirtualCardCache stores unmasked virtual cards (containing full PAN, CVC, and expiry) per web::WebState. The cache is only invalidated in DidFinishNavigation when !IsSameDocument(). Because DidFinishNavigation on iOS only fires for main-frame navigations, the sensitive cache persists across subframe navigations and cross-origin iframes.

2. Re-authentication Bypass: In card_coordinator.mm, the onDismissCompleted:fieldType: method checks the cache before requesting a full card. On a cache hit, it bypasses ManualFillFullCardRequester and calls onFullCardRequestSucceeded directly, bypassing all authentication checks, including Payments Mandatory Re-auth.

3. Cross-Origin Focus Capture via Missing Gesture Check: ManualFillInjectionHandler tracks the frame and element to be filled via didRegisterFormActivity. This handler updates lastFocusedElementFrameIdentifier whenever any frame emits a ‘focus’ event. Crucially, it does not check if the focus event was triggered by a user gesture (params.has_user_gesture). A malicious iframe can programmatically call focus() on an input field to silently become the target for the next manual-fill injection.

Potential Attack Scenario

Note: These are suggested steps; a working proof of concept has not yet been executed.

  1. A user visits https://legitimate-merchant.com/checkout, which embeds a malicious iframe from https://attacker.com.
  2. The user focuses the merchant’s credit card input, triggering the Manual Fill accessory.
  3. The user taps the “Card Number” chip for a VCN, authenticates, and the unmasked PAN is injected into the merchant’s frame. The unmasked card is now cached in the WebState.
  4. The attacker’s iframe executes JavaScript to programmatically focus a hidden input (element.focus()).
  5. The iOS Autofill JS sends a 'focus' event to the browser. Because ManualFillInjectionHandler ignores the fact that has_user_gesture is false, it updates the target frame to the attacker’s iframe.
  6. The user taps the “CVC” chip on the accessory to continue filling the form.
  7. CardCoordinator finds the card in the cache, bypasses authentication, and injects the unmasked CVC directly into the attacker’s iframe.
  8. The attacker exfiltrates the sensitive data via an oninput event.

Suggested Fix

  1. Enforce User Gesture: Modify ManualFillInjectionHandler::webState:didRegisterFormActivity:inFrame: to ignore focus events where params.has_user_gesture is false.
  2. Isolate Cache: The ManualFillVirtualCardCache should be keyed by WebFrame ID or Origin, not just the WebState, to prevent cross-frame leakage. Alternatively, the cache should be explicitly cleared when the focused frame changes.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


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