Low watch CVSS 5.4 chrome Spoofing 🔧 Commit mapped

Overview

Low
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Mobile
DescriptionUI misrepresentation in Mobile
ComponentMobile
Sub-componentiOS
Bug ClassSpoofing
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker533102653
Fix commitac619336ac2b (chromium/src) +665/-33
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-07-09 (per vendor advisory)
Fix landed2026-09-04 (public git)
Disclosed2026-09-22
Patch gap18 days — fix public in source before the advisory

Background

`VcardTabHelper`
a per-WebState helper on iOS Chrome that downloads vCard contact files and asks its delegate to present them, or defers presentation while the tab is hidden.
`VcardCoordinator`
the coordinator installed as the VcardTabHelperDelegate across all tabs, responsible for presenting and dismissing the ContactsUI contact card.
`VcardMediator`
the class introduced by this fix that observes the active WebState and the WebStateList via base::ScopedObservation and tells the coordinator when to dismiss the vCard UI.
Deferred pending vCard
a vCard whose presentation is postponed while its tab is backgrounded, held until the tab becomes active again.

Root Cause Analysis

VcardTabHelper previously notified its delegate to dismiss the contact UI directly on navigation, but because a single VcardCoordinator was registered as the delegate for every tab’s VcardTabHelper, a navigation occurring in a background tab would invoke the dismiss path and tear down the contact card that belonged to the foreground tab. The violated invariant is that only the tab actually presenting the vCard UI should be able to dismiss it; dismissal was keyed on which helper fired rather than on which WebState owned the presented UI. Symmetrically, a deferred pending vCard could survive a navigation and later be presented over an unrelated page, meaning contact UI was shown detached from the content that produced it.

The fix introduces VcardMediator, which watches only the active WebState/WebStateList and calls dismissVcardForWebState:; VcardCoordinator now tracks _presentedWebState and only dismisses when the navigating WebState matches the one currently presented. VcardTabHelper additionally discards deferred pending vCards on cross-document navigation start or commit so stale contact prompts cannot appear over a new page.

Key insight
The core mistake was routing dismissal through a shared delegate that could not distinguish which tab owned the on-screen UI, so a background tab’s navigation controlled the foreground tab’s UI; the fix scopes every dismissal to a specific WebState (via VcardMediator observing the active tab and VcardCoordinator comparing against _presentedWebState) and drops deferred vCards on navigation.

Attack Path

  1. Present a vCard in the foreground A user downloads a vCard on the current (foreground) tab, and VcardCoordinator presents the ContactsUI contact card for that WebState.
  2. Trigger a background navigation A background tab navigates (start or commit), for example via an attacker-controlled page in another tab, causing that tab’s VcardTabHelper to fire the shared dismiss path. (inferred) that the background navigation is attacker-driven; the patch only shows that a background navigation reaches the dismiss delegate.
  3. Cross-wire the dismissal Because VcardCoordinator was the delegate for all tabs and did not check which WebState owned the presented UI, the background navigation dismisses (or could otherwise desynchronize) the vCard UI shown for the unrelated foreground tab.
  4. Show contact UI over the wrong page Alternatively, a deferred pending vCard held while a tab is hidden could be presented after navigation, surfacing a contact card over an unrelated page and misrepresenting which site produced it.

Impact Assessment

An attacker gains UI misrepresentation on iOS Chrome: contact/vCard UI can be dismissed or displayed out of sync with the tab and page that actually produced it, letting a background or navigated context manipulate what the user sees in the foreground. This runs in the browser UI context and requires that a vCard be presented or deferred while a separate navigation occurs. The advisory classifies this as a LogicError causing UI misrepresentation in Mobile and states no exploited-in-the-wild status; any escalation beyond UI confusion is (inferred) and not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
if
ios/chrome/browser/download/coordinator/vcard_coordinator.mm
modified
WebStateList
ios/chrome/browser/download/coordinator/vcard_mediator.h
modified
if
ios/chrome/browser/download/coordinator/vcard_mediator.mm
modified
WebState
ios/chrome/browser/download/coordinator/vcard_mediator_delegate.h
modified

Files Changed

  • ios/chrome/browser/download/coordinator/vcard_mediator.h
  • ios/chrome/browser/download/coordinator/vcard_mediator.mm
  • ios/chrome/browser/download/coordinator/vcard_coordinator.mm
  • ios/chrome/browser/download/ui/vcard_egtest.mm
  • ios/chrome/browser/download/coordinator/BUILD.gn
  • ios/chrome/browser/download/model/vcard_tab_helper_unittest.mm
  • ios/chrome/browser/download/coordinator/vcard_mediator_unittest.mm
  • ios/chrome/browser/download/model/vcard_tab_helper.mm
  • ios/chrome/browser/download/coordinator/vcard_mediator_delegate.h
  • ios/chrome/browser/download/ui/BUILD.gn
  • ios/chrome/browser/download/model/vcard_tab_helper.h
  • ios/chrome/browser/download/coordinator/vcard_coordinator_unittest.mm

Audit Directions

  • Shared delegate without owner check
    Look for a single coordinator/delegate registered across all tabs’ per-WebState helpers where a callback acts on UI without verifying which WebState owns it.
  • Deferred/pending UI across navigation
    Audit any state that is deferred while a tab is backgrounded and later presented, and confirm it is invalidated on cross-document navigation start or commit.
  • Navigation-driven UI teardown
    Review dismissal or presentation triggered by WebState navigation events to ensure it is scoped to the active/presenting tab rather than any tab in the WebStateList.
From ac619336ac2ba8c3d85c6a3d1862790a77d81882 Mon Sep 17 00:00:00 2001
From: Quentin Pubert <[email protected]>
Date: Fri, 04 Sep 2026 03:57:42 -0700
Subject: [PATCH] [iOS] Dismiss vCard UI and drop deferred state on navigation

VcardTabHelper downloads vCard contact files and asks its delegate to
present them, or defers presentation while a tab is hidden.

Previously, VcardTabHelper directly notified its delegate to dismiss the
contact UI on navigation. Because VcardCoordinator was installed as the
delegate across all tabs, navigations in background tabs would
incorrectly dismiss the vCard UI currently shown in the foreground tab.

This change:
1. Introduces VcardMediator to observe the active WebState and
   WebStateList via base::ScopedObservation. It instructs
   VcardCoordinator to dismiss the contact UI when the active tab
   navigates (start or commit), when the active tab switches, or when
   the active tab is closed, while safely ignoring background tabs.
2. Removes dismissVcard from VcardTabHelper and VcardTabHelperDelegate.
3. Ensures VcardTabHelper discards deferred pending vCards upon
   cross-document navigation start or commit so they are not shown over
   unrelated pages.
4. Adds VcardMediatorTest and VcardEGTest to verify the dismissal and
   download behavior.

Bug: 533074595, 533102653
Change-Id: I6aa64b69b31376b5a50446801edc5bd1875f6d50
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8311572
Auto-Submit: Quentin Pubert <[email protected]>
Reviewed-by: Olivier Robin <[email protected]>
Commit-Queue: Quentin Pubert <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1692333}
---
diff --git a/ios/chrome/browser/download/coordinator/vcard_coordinator.mm b/ios/chrome/browser/download/coordinator/vcard_coordinator.mm
index d6c6fc6..5f42070 100644
--- a/ios/chrome/browser/download/coordinator/vcard_coordinator.mm
+++ b/ios/chrome/browser/download/coordinator/vcard_coordinator.mm
@@ -2,37 +2,47 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-#import "ios/chrome/browser/download/coordinator//vcard_coordinator.h"
+#import "ios/chrome/browser/download/coordinator/vcard_coordinator.h"
 
 #import <ContactsUI/ContactsUI.h>
 
-#import "base/scoped_observation.h"
+#import "base/memory/raw_ptr.h"
+#import "ios/chrome/browser/download/coordinator/vcard_mediator.h"
+#import "ios/chrome/browser/download/coordinator/vcard_mediator_delegate.h"
 #import "ios/chrome/browser/download/model/vcard_tab_helper.h"
 #import "ios/chrome/browser/download/model/vcard_tab_helper_delegate.h"
 #import "ios/chrome/browser/shared/model/browser/browser.h"
+#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
 #import "ios/chrome/browser/tabs/model/tabs_dependency_installer_bridge.h"
-#import "ios/web/public/web_state_observer_bridge.h"
 
 @interface VcardCoordinator () <TabsDependencyInstalling,
+                                VcardMediatorDelegate,
                                 VcardTabHelperDelegate>
-
-// NavigationController that contains a viewController used to display a
-// contact.
-@property(nonatomic, strong) UINavigationController* navigationViewController;
-
 @end
 
 @implementation VcardCoordinator {
   // Bridge which observes WebStateList and alerts this coordinator when this
-  // needs to register the Mediator with a new WebState.
+  // needs to register with a new WebState.
   TabsDependencyInstallerBridge _dependencyInstallerBridge;
+  // Mediator that monitors the active WebState and dismisses vCard on
+  // navigation.
+  VcardMediator* _mediator;
+  // NavigationController that contains a viewController used to display a
+  // contact.
+  UINavigationController* _navigationViewController;
+  // The WebState for which `_navigationViewController` is currently presented.
+  raw_ptr<web::WebState> _presentedWebState;
 }
 
 - (instancetype)initWithBaseViewController:(UIViewController*)baseViewController
                                    browser:(Browser*)browser {
   if ((self = [super initWithBaseViewController:baseViewController
                                         browser:browser])) {
+    _presentedWebState = nullptr;
     _dependencyInstallerBridge.StartObserving(self, browser);
+    _mediator =
+        [[VcardMediator alloc] initWithWebStateList:browser->GetWebStateList()];
+    _mediator.delegate = self;
   }
   return self;
 }
@@ -41,17 +51,32 @@
   // Stop observing the WebStateList before destroying the bridge object.
   _dependencyInstallerBridge.StopObserving();
 
-  self.navigationViewController = nil;
+  [_mediator disconnect];
+  _mediator = nil;
+
+  if (_navigationViewController) {
+    [_navigationViewController.presentingViewController
+        dismissViewControllerAnimated:NO
+                           completion:nil];
+    _navigationViewController = nil;
+    _presentedWebState = nullptr;
+  }
 }
 
 #pragma mark - TabsDependencyInstalling methods
 
 - (void)webStateInserted:(web::WebState*)webState {
-  VcardTabHelper::FromWebState(webState)->set_delegate(self);
+  VcardTabHelper* tabHelper = VcardTabHelper::FromWebState(webState);
+  if (tabHelper) {
+    tabHelper->set_delegate(self);
+  }
 }
 
 - (void)webStateRemoved:(web::WebState*)webState {
-  VcardTabHelper::FromWebState(webState)->set_delegate(nil);
+  VcardTabHelper* tabHelper = VcardTabHelper::FromWebState(webState);
+  if (tabHelper) {
+    tabHelper->set_delegate(nil);
+  }
 }
 
 - (void)webStateDeleted:(web::WebState*)webState {
@@ -63,16 +88,39 @@
   // Nothing to do.
 }
 
-#pragma mark - Private
+#pragma mark - VcardMediatorDelegate
 
-// Dismisses the the `navigationViewController`.
-- (void)dismissButtonTapped {
-  [self.baseViewController dismissViewControllerAnimated:true completion:nil];
+- (void)dismissVcardForWebState:(web::WebState*)webState {
+  if (_navigationViewController && _presentedWebState == webState) {
+    [_navigationViewController.presentingViewController
+        dismissViewControllerAnimated:YES
+                           completion:nil];
+    _navigationViewController = nil;
+    _presentedWebState = nullptr;
+  }
 }
 
-// Retreives contact informations from `data` and presents it.
+#pragma mark - VcardTabHelperDelegate
+
+- (void)openVcardFromData:(NSData*)vcardData {
+  DCHECK(vcardData);
+  [self presentContactVCardFromData:vcardData];
+}
+
+#pragma mark - Private
+
+// Dismisses the `_navigationViewController`.
+- (void)dismissButtonTapped {
+  [_navigationViewController.presentingViewController
+      dismissViewControllerAnimated:YES
+                         completion:nil];
+  _navigationViewController = nil;
+  _presentedWebState = nullptr;
+}
+
+// Retrieves contact information from `data` and presents it.
 - (void)presentContactVCardFromData:(NSData*)vcardData {
-  // TODO(crbug.com/40208267): Vcard download code only support the first
+  // TODO(crbug.com/40208267): Vcard download code only supports the first
   // contact.
   CNContact* contact =
       [[CNContactVCardSerialization contactsWithData:vcardData
@@ -93,18 +141,12 @@
                            action:@selector(dismissButtonTapped)];
   contactViewController.navigationItem.leftBarButtonItem = dismissButton;
 
-  self.navigationViewController = [[UINavigationController alloc]
+  _navigationViewController = [[UINavigationController alloc]
       initWithRootViewController:contactViewController];
-  [self.baseViewController presentViewController:self.navigationViewController
+  _presentedWebState = self.browser->GetWebStateList()->GetActiveWebState();
+  [self.baseViewController presentViewController:_navigationViewController
                                         animated:YES
                                       completion:nil];
 }
 
-#pragma mark - VcardTabHelperDelegate
-
-- (void)openVcardFromData:(NSData*)vcardData {
-  DCHECK(vcardData);
-  [self presentContactVCardFromData:vcardData];
-}
-
 @end
diff --git a/ios/chrome/browser/download/coordinator/vcard_mediator.h b/ios/chrome/browser/download/coordinator/vcard_mediator.h
new file mode 100644
index 0000000..0ce16ca
--- /dev/null
+++ b/ios/chrome/browser/download/coordinator/vcard_mediator.h
@@ -0,0 +1,32 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef IOS_CHROME_BROWSER_DOWNLOAD_COORDINATOR_VCARD_MEDIATOR_H_
+#define IOS_CHROME_BROWSER_DOWNLOAD_COORDINATOR_VCARD_MEDIATOR_H_
+
+#import <Foundation/Foundation.h>
+
+@protocol VcardMediatorDelegate;
+class WebStateList;
+
+// Mediator that monitors the active WebState in WebStateList and instructs
+// VcardCoordinator to dismiss any presented vCard UI when navigation or
+// WebState switching occurs.
+@interface VcardMediator : NSObject
+
+// Delegate for vCard UI actions.
+@property(nonatomic, weak) id<VcardMediatorDelegate> delegate;
+
+// Designated initializer.
+- (instancetype)initWithWebStateList:(WebStateList*)webStateList
+    NS_DESIGNATED_INITIALIZER;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+// Disconnects observations.
+- (void)disconnect;
+
+@end
+
+#endif  // IOS_CHROME_BROWSER_DOWNLOAD_COORDINATOR_VCARD_MEDIATOR_H_
diff --git a/ios/chrome/browser/download/coordinator/vcard_mediator.mm b/ios/chrome/browser/download/coordinator/vcard_mediator.mm
new file mode 100644
index 0000000..498d3d4
--- /dev/null
+++ b/ios/chrome/browser/download/coordinator/vcard_mediator.mm
@@ -0,0 +1,110 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/download/coordinator/vcard_mediator.h"
+
+#import "base/check.h"
+#import "base/scoped_observation.h"
+#import "ios/chrome/browser/download/coordinator/vcard_mediator_delegate.h"
+#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
+#import "ios/chrome/browser/shared/model/web_state_list/web_state_list_observer_bridge.h"
+#import "ios/web/public/navigation/navigation_context.h"
+#import "ios/web/public/web_state.h"
+#import "ios/web/public/web_state_observer_bridge.h"
+
+@interface VcardMediator () <CRWWebStateObserver, WebStateListObserving>
+@end
+
+@implementation VcardMediator {
+  std::unique_ptr<WebStateListObserverBridge> _webStateListObserver;
+  std::unique_ptr<
+      base::ScopedObservation<WebStateList, WebStateListObserverBridge>>
+      _webStateListObservation;
+  std::unique_ptr<web::WebStateObserverBridge> _activeWebStateObserver;
+  std::unique_ptr<
+      base::ScopedObservation<web::WebState, web::WebStateObserverBridge>>
+      _activeWebStateObservation;
+}
+
+- (instancetype)initWithWebStateList:(WebStateList*)webStateList {
+  self = [super init];
+  if (self) {
+    CHECK(webStateList);
+    _webStateListObserver = std::make_unique<WebStateListObserverBridge>(self);
+    _webStateListObservation = std::make_unique<
+        base::ScopedObservation<WebStateList, WebStateListObserverBridge>>(
+        _webStateListObserver.get());
+    _webStateListObservation->Observe(webStateList);
+
+    _activeWebStateObserver =
+        std::make_unique<web::WebStateObserverBridge>(self);
+    _activeWebStateObservation = std::make_unique<
+        base::ScopedObservation<web::WebState, web::WebStateObserverBridge>>(
+        _activeWebStateObserver.get());
+    [self updateActiveWebState:webStateList->GetActiveWebState()];
+  }
+  return self;
+}
+
+- (void)disconnect {
+  self.delegate = nil;
+  _activeWebStateObservation.reset();
+  _activeWebStateObserver.reset();
+  _webStateListObservation.reset();
+  _webStateListObserver.reset();
+}
+
+#pragma mark - WebStateListObserving
+
+- (void)didChangeWebStateList:(WebStateList*)webStateList
+                       change:(const WebStateListChange&)change
+                       status:(const WebStateListStatus&)status {
+  if (status.active_web_state_change()) {
+    [self updateActiveWebState:status.new_active_web_state];
+    if (status.old_active_web_state) {
+      [self.delegate dismissVcardForWebState:status.old_active_web_state];
+    }
+  }
+}
+
+- (void)webStateListDestroyed:(WebStateList*)webStateList {
+  _webStateListObservation.reset();
+  _webStateListObserver.reset();
+}
+
+#pragma mark - CRWWebStateObserver
+
+- (void)webState:(web::WebState*)webState
+    didStartNavigation:(web::NavigationContext*)navigationContext {
+  if (!navigationContext->IsSameDocument()) {
+    [self.delegate dismissVcardForWebState:webState];
+  }
+}
+
+- (void)webState:(web::WebState*)webState
+    didFinishNavigation:(web::NavigationContext*)navigationContext {
+  if (navigationContext->HasCommitted() &&
+      !navigationContext->IsSameDocument()) {
+    [self.delegate dismissVcardForWebState:webState];
+  }
+}
+
+- (void)webStateDestroyed:(web::WebState*)webState {
+  _activeWebStateObservation->Reset();
+  [self.delegate dismissVcardForWebState:webState];
+}
+
+#pragma mark - Private
+
+- (void)updateActiveWebState:(web::WebState*)newActiveWebState {
+  if (!_activeWebStateObservation) {
+    return;
+  }
+  _activeWebStateObservation->Reset();
+  if (newActiveWebState) {
+    _activeWebStateObservation->Observe(newActiveWebState);
+  }
+}
+
+@end
diff --git a/ios/chrome/browser/download/coordinator/vcard_mediator_delegate.h b/ios/chrome/browser/download/coordinator/vcard_mediator_delegate.h
new file mode 100644
index 0000000..b873242d
--- /dev/null
+++ b/ios/chrome/browser/download/coordinator/vcard_mediator_delegate.h
@@ -0,0 +1,23 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef IOS_CHROME_BROWSER_DOWNLOAD_COORDINATOR_VCARD_MEDIATOR_DELEGATE_H_
+#define IOS_CHROME_BROWSER_DOWNLOAD_COORDINATOR_VCARD_MEDIATOR_DELEGATE_H_
+
+#import <Foundation/Foundation.h>
+
+namespace web {
+class WebState;
+}
+
+// Delegate protocol for VcardMediator to dismiss presented vCard UI.
+@protocol VcardMediatorDelegate <NSObject>
+
+// Instructs the delegate to dismiss any presented vCard UI associated with
+// `webState`.
+- (void)dismissVcardForWebState:(web::WebState*)webState;
+
+@end
+
+#endif  // IOS_CHROME_BROWSER_DOWNLOAD_COORDINATOR_VCARD_MEDIATOR_DELEGATE_H_
diff --git a/ios/chrome/browser/download/model/vcard_tab_helper.h b/ios/chrome/browser/download/model/vcard_tab_helper.h
index 42203944..fe6bc5f 100644
--- a/ios/chrome/browser/download/model/vcard_tab_helper.h
+++ b/ios/chrome/browser/download/model/vcard_tab_helper.h
@@ -56,6 +56,8 @@
   void WasShown(web::WebState* web_state) override;
   void DidStartNavigation(web::WebState* web_state,
                           web::NavigationContext* navigation_context) override;
+  void DidFinishNavigation(web::WebState* web_state,
+                           web::NavigationContext* navigation_context) override;
 
   // web::DownloadTaskObserver overrides:
   void OnDownloadUpdated(web::DownloadTask* task) override;
diff --git a/ios/chrome/browser/download/model/vcard_tab_helper.mm b/ios/chrome/browser/download/model/vcard_tab_helper.mm
index c175013..44c4ae8 100644
--- a/ios/chrome/browser/download/model/vcard_tab_helper.mm
+++ b/ios/chrome/browser/download/model/vcard_tab_helper.mm
@@ -60,6 +60,16 @@
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/download/coordinator/vcard_coordinator_unittest.mm b/ios/chrome/browser/download/coordinator/vcard_coordinator_unittest.mm
index b94507f..fe7484a 100644
--- a/ios/chrome/browser/download/coordinator/vcard_coordinator_unittest.mm
+++ b/ios/chrome/browser/download/coordinator/vcard_coordinator_unittest.mm
@@ -4,27 +4,35 @@
 
 #import "ios/chrome/browser/download/coordinator/vcard_coordinator.h"
 
+#import "base/test/ios/wait_util.h"
+#import "ios/chrome/browser/download/model/download_test_util.h"
 #import "ios/chrome/browser/download/model/vcard_tab_helper.h"
 #import "ios/chrome/browser/download/model/vcard_tab_helper_delegate.h"
 #import "ios/chrome/browser/shared/model/browser/test/test_browser.h"
 #import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
+#import "ios/chrome/browser/shared/model/utils/mime_type_util.h"
 #import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
 #import "ios/chrome/browser/shared/model/web_state_list/web_state_opener.h"
 #import "ios/chrome/test/scoped_key_window.h"
+#import "ios/web/public/test/fakes/fake_download_task.h"
 #import "ios/web/public/test/fakes/fake_web_state.h"
 #import "ios/web/public/test/web_task_environment.h"
 #import "testing/platform_test.h"
 
+using base::test::ios::kWaitForUIElementTimeout;
+using base::test::ios::WaitUntilConditionOrTimeout;
+
 // Test fixture for VcardCoordinatorTest class.
 class VcardCoordinatorTest : public PlatformTest {
  protected:
   VcardCoordinatorTest() {
     profile_ = TestProfileIOS::Builder().Build();
     browser_ = std::make_unique<TestBrowser>(profile_.get());
-    coordinator_ =
-        [[VcardCoordinator alloc] initWithBaseViewController:nil
-                                                     browser:browser_.get()];
-    [scoped_key_window_.Get() setRootViewController:nil];
+    base_view_controller_ = [[UIViewController alloc] init];
+    coordinator_ = [[VcardCoordinator alloc]
+        initWithBaseViewController:base_view_controller_
+                           browser:browser_.get()];
+    [scoped_key_window_.Get() setRootViewController:base_view_controller_];
 
     [coordinator_ start];
   }
@@ -35,6 +43,7 @@
   web::WebTaskEnvironment task_environment_;
   std::unique_ptr<TestProfileIOS> profile_;
   std::unique_ptr<TestBrowser> browser_;
+  UIViewController* base_view_controller_;
   VcardCoordinator* coordinator_;
   ScopedKeyWindow scoped_key_window_;
 };
@@ -59,3 +68,37 @@
   browser_->GetWebStateList()->ReplaceWebStateAt(0, std::move(web_state3));
   EXPECT_TRUE(VcardTabHelper::FromWebState(web_state_ptr3)->delegate());
 }
+
+// Tests that a vCard received while a WebState is in the background is
+// presented when that WebState is shown.
+TEST_F(VcardCoordinatorTest, ShowVcardWhenTabActivated) {
+  // Add background web state.
+  auto web_state = std::make_unique<web::FakeWebState>();
+  web_state->WasHidden();
+  web::FakeWebState* web_state_ptr = web_state.get();
+  VcardTabHelper::CreateForWebState(web_state_ptr);
+  browser_->GetWebStateList()->InsertWebState(std::move(web_state));
+
+  // Trigger vCard download in background web state.
+  std::string pass_data = testing::GetTestFileContents(testing::kVcardFilePath);
+  NSData* data = [NSData dataWithBytes:pass_data.data()
+                                length:pass_data.size()];
+  auto task = std::make_unique<web::FakeDownloadTask>(
+      GURL("https://example.test/vcard.vcf"), kVcardMimeType);
+  web::FakeDownloadTask* task_ptr = task.get();
+  VcardTabHelper::FromWebState(web_state_ptr)->Download(std::move(task));
+  task_ptr->SetResponseData(data);
+  task_ptr->SetDone(true);
+
+  // UI should not be presented yet while web state is hidden.
+  EXPECT_FALSE(base_view_controller_.presentedViewController);
+
+  // Show the web state.
+  web_state_ptr->WasShown();
+
+  // UI should now be presented.
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForUIElementTimeout, ^{
+    return [base_view_controller_.presentedViewController class] ==
+           [UINavigationController class];
+  }));
+}
diff --git a/ios/chrome/browser/download/coordinator/vcard_mediator_unittest.mm b/ios/chrome/browser/download/coordinator/vcard_mediator_unittest.mm
new file mode 100644
index 0000000..5710d50
--- /dev/null
+++ b/ios/chrome/browser/download/coordinator/vcard_mediator_unittest.mm
@@ -0,0 +1,189 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/download/coordinator/vcard_mediator.h"
+
+#import <memory>
+
+#import "base/test/task_environment.h"
+#import "ios/chrome/browser/download/coordinator/vcard_mediator_delegate.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/chrome/browser/shared/model/web_state_list/web_state_opener.h"
+#import "ios/web/public/test/fakes/fake_navigation_context.h"
+#import "ios/web/public/test/fakes/fake_web_state.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/platform_test.h"
+#import "third_party/ocmock/OCMock/OCMock.h"
+#import "third_party/ocmock/gtest_support.h"
+
+class VcardMediatorTest : public PlatformTest {
+ public:
+  VcardMediatorTest() {
+    web_state_list_ = std::make_unique<WebStateList>(&web_state_list_delegate_);
+    mediator_ =
+        [[VcardMediator alloc] initWithWebStateList:web_state_list_.get()];
+    delegate_mock_ = OCMStrictProtocolMock(@protocol(VcardMediatorDelegate));
+    mediator_.delegate = delegate_mock_;
+  }
+
+  ~VcardMediatorTest() override { [mediator_ disconnect]; }
+
+ protected:
+  base::test::TaskEnvironment task_environment_;
+  FakeWebStateListDelegate web_state_list_delegate_;
+  std::unique_ptr<WebStateList> web_state_list_;
+  VcardMediator* mediator_;
+  id delegate_mock_;
+};
+
+// Tests that vCard is dismissed when active WebState starts cross-document
+// navigation.
+TEST_F(VcardMediatorTest, DismissOnNavigationStart) {
+  auto web_state = std::make_unique<web::FakeWebState>();
+  web::FakeWebState* web_state_ptr = web_state.get();
+  web_state_list_->InsertWebState(
+      std::move(web_state),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  OCMExpect([delegate_mock_ dismissVcardForWebState:web_state_ptr]);
+
+  web::FakeNavigationContext context;
+  context.SetIsSameDocument(false);
+  web_state_ptr->OnNavigationStarted(&context);
+
+  EXPECT_OCMOCK_VERIFY(delegate_mock_);
+}
+
+// Tests that vCard is NOT dismissed when active WebState starts same-document
+// navigation.
+TEST_F(VcardMediatorTest, DoNotDismissOnSameDocumentNavigationStart) {
+  auto web_state = std::make_unique<web::FakeWebState>();
+  web::FakeWebState* web_state_ptr = web_state.get();
+  web_state_list_->InsertWebState(
+      std::move(web_state),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  // No expectation set on delegate_mock_. Invocation would fail strict mock.
+  web::FakeNavigationContext context;
+  context.SetIsSameDocument(true);
+  web_state_ptr->OnNavigationStarted(&context);
+
+  EXPECT_OCMOCK_VERIFY(delegate_mock_);
+}
+
+// Tests that vCard is dismissed when active WebState finishes committed
+// cross-document navigation.
+TEST_F(VcardMediatorTest, DismissOnNavigationFinish) {
+  auto web_state = std::make_unique<web::FakeWebState>();
+  web::FakeWebState* web_state_ptr = web_state.get();
+  web_state_list_->InsertWebState(
+      std::move(web_state),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  OCMExpect([delegate_mock_ dismissVcardForWebState:web_state_ptr]);
+
+  web::FakeNavigationContext context;
+  context.SetHasCommitted(true);
+  context.SetIsSameDocument(false);
+  web_state_ptr->OnNavigationFinished(&context);
+
+  EXPECT_OCMOCK_VERIFY(delegate_mock_);
+}
+
+// Tests that vCard is NOT dismissed when active WebState finishes same-document
+// navigation.
+TEST_F(VcardMediatorTest, DoNotDismissOnSameDocumentNavigationFinish) {
+  auto web_state = std::make_unique<web::FakeWebState>();
+  web::FakeWebState* web_state_ptr = web_state.get();
+  web_state_list_->InsertWebState(
+      std::move(web_state),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  // No expectation set on delegate_mock_. Invocation would fail strict mock.
+  web::FakeNavigationContext context;
+  context.SetHasCommitted(true);
+  context.SetIsSameDocument(true);
+  web_state_ptr->OnNavigationFinished(&context);
+
+  EXPECT_OCMOCK_VERIFY(delegate_mock_);
+}
+
+// Tests that vCard is NOT dismissed when active WebState finishes uncommitted
+// navigation.
+TEST_F(VcardMediatorTest, DoNotDismissOnUncommittedNavigationFinish) {
+  auto web_state = std::make_unique<web::FakeWebState>();
+  web::FakeWebState* web_state_ptr = web_state.get();
+  web_state_list_->InsertWebState(
+      std::move(web_state),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  // No expectation set on delegate_mock_. Invocation would fail strict mock.
+  web::FakeNavigationContext context;
+  context.SetHasCommitted(false);
+  context.SetIsSameDocument(false);
+  web_state_ptr->OnNavigationFinished(&context);
+
+  EXPECT_OCMOCK_VERIFY(delegate_mock_);
+}
+
+// Tests that vCard is dismissed when active WebState changes.
+TEST_F(VcardMediatorTest, DismissOnActiveWebStateChange) {
+  auto web_state1 = std::make_unique<web::FakeWebState>();
+  web::FakeWebState* web_state1_ptr = web_state1.get();
+  web_state_list_->InsertWebState(
+      std::move(web_state1),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  OCMExpect([delegate_mock_ dismissVcardForWebState:web_state1_ptr]);
+
+  auto web_state2 = std::make_unique<web::FakeWebState>();
+  web_state_list_->InsertWebState(
+      std::move(web_state2),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  EXPECT_OCMOCK_VERIFY(delegate_mock_);
+}
+
+// Tests that vCard is dismissed when active WebState is destroyed, and does
+// not crash even when dismissal triggers mediator disconnection.
+TEST_F(VcardMediatorTest, DismissOnWebStateDestroyed) {
+  auto web_state = std::make_unique<web::FakeWebState>();
+  web::FakeWebState* web_state_ptr = web_state.get();
+  web_state_list_->InsertWebState(
+      std::move(web_state),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  OCMExpect([delegate_mock_ dismissVcardForWebState:web_state_ptr])
+      .andDo(^(NSInvocation* invocation) {
+        [mediator_ disconnect];
+      });
+
+  web_state_list_->CloseWebStateAt(0, WebStateList::ClosingReason::kDefault);
+
+  EXPECT_OCMOCK_VERIFY(delegate_mock_);
+}
+
+// Tests that navigation in a background WebState does NOT dismiss the vCard
+// on the active WebState.
+TEST_F(VcardMediatorTest, BackgroundWebStateNavigationDoesNotDismiss) {
+  auto active_web_state = std::make_unique<web::FakeWebState>();
+  web_state_list_->InsertWebState(
+      std::move(active_web_state),
+      WebStateList::InsertionParams::Automatic().Activate());
+
+  auto background_web_state = std::make_unique<web::FakeWebState>();
+  web::FakeWebState* background_web_state_ptr = background_web_state.get();
+  web_state_list_->InsertWebState(std::move(background_web_state),
+                                  WebStateList::InsertionParams::AtIndex(1));
+
+  // No expectations on delegate_mock_. Any invocation would fail strict mock.
+  web::FakeNavigationContext context;
+  context.SetIsSameDocument(false);
+  context.SetHasCommitted(true);
+  background_web_state_ptr->OnNavigationStarted(&context);
+  background_web_state_ptr->OnNavigationFinished(&context);
+
+  EXPECT_OCMOCK_VERIFY(delegate_mock_);
+}
diff --git a/ios/chrome/browser/download/model/vcard_tab_helper_unittest.mm b/ios/chrome/browser/download/model/vcard_tab_helper_unittest.mm
index b14dc9f0..1f5ede55 100644
--- a/ios/chrome/browser/download/model/vcard_tab_helper_unittest.mm
+++ b/ios/chrome/browser/download/model/vcard_tab_helper_unittest.mm
@@ -119,8 +119,8 @@
 }
 
 // Tests that vCard presentation deferred while the WebState was hidden is
-// dropped upon observing a cross-document navigation.
-TEST_F(VcardTabHelperTest, DeferredVcardDroppedOnNavigation) {
+// dropped upon observing a cross-document navigation start.
+TEST_F(VcardTabHelperTest, DeferredVcardDroppedOnNavigationStart) {
   web_state_.WasHidden();
 
   auto task =
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.