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.
Tracker533074595
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

vCard
a standard contact file format that iOS Chrome downloads and presents through a system contact UI so the user can save the contact.
`VcardTabHelper`
a per-WebState helper that downloads a vCard, tracks a deferred pending vCard while its tab is hidden, and notifies its delegate to present or dismiss the contact UI.
`VcardCoordinator`
a browser-scoped object installed as the VcardTabHelperDelegate for every tab, which owns the presented UINavigationController showing the contact.
`VcardMediator`
a new object introduced by the fix that observes the active WebState and WebStateList via base::ScopedObservation and tells the coordinator to dismiss the contact UI on active-tab navigation, switch, or close.

Root Cause Analysis

Because a single VcardCoordinator was installed as the VcardTabHelperDelegate across all tabs, dismissal and presentation decisions were driven by whichever VcardTabHelper fired, not by whether that tab was the one currently shown to the user. The coordinator tracked no notion of which WebState the presented contact UI belonged to, so navigation events routed through per-tab helpers could act on the shared foreground presentation regardless of which tab actually navigated. This violated the invariant that the vCard contact UI must correspond to, and be controlled by, the active foreground tab.

The fix moves dismissal decisions into VcardMediator, which observes only the active WebState and WebStateList, and adds _presentedWebState tracking in the coordinator so dismissVcardForWebState: only tears down the UI when it matches the presenting tab. It also removes the direct dismissVcard delegate hook and makes VcardTabHelper discard deferred pending vCards on cross-document navigation start or commit, preventing a stale contact from surfacing over an unrelated page.

Key insight
The core mistake was that a browser-wide delegate acted on the shared foreground contact UI in response to per-tab events without checking tab identity, letting background-tab activity manipulate the visible UI. The fix scopes dismissal to the active tab via VcardMediator and gates it on a recorded _presentedWebState, so only the tab that owns the presentation can dismiss it and stale deferred vCards are dropped on navigation.

Attack Path

  1. Trigger a vCard download in the foreground tab The user opens or is directed to a page that serves a vCard, and VcardTabHelper presents the contact UI through VcardCoordinator in the active tab.
  2. Cause a background tab to navigate A background tab (for example one running script, a redirect, or a timer) starts or commits a navigation, firing its own per-tab VcardTabHelper events. (inferred)
  3. Cross-tab dismissal or stale presentation Because the shared coordinator handled these events without tab-identity checks, the background navigation could incorrectly dismiss the foreground contact UI, or a previously deferred pending vCard could be shown over an unrelated foreground page.
  4. UI misrepresentation The user sees the contact UI dismissed unexpectedly, or a vCard rendered in the context of a page that did not produce it, creating a mismatch between the displayed contact and the actual page. (inferred)

Impact Assessment

An attacker gains UI misrepresentation in iOS Chrome, causing the vCard contact UI to be dismissed by unrelated background activity or shown over an unrelated page, which could be leveraged to mislead the user about which page a contact originated from. This occurs in the browser (UI/coordinator) context on Mobile and requires the user to encounter a vCard download alongside a navigating background tab. The advisory classifies this as a LogicError causing UI misrepresentation and does not state exploitation in the wild; any escalation beyond spoofing/UI confusion is not demonstrated by the patch. (inferred)

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 delegates across per-tab objects
    Audit coordinators installed as delegates for every WebState/tab that act on shared foreground UI without checking which tab the event belongs to.
  • Deferred/pending UI state on navigation
    Look for helpers that hold deferred presentations (pending vCards, dialogs, prompts) that are not discarded on cross-document navigation start or commit, allowing stale UI over unrelated pages.
  • Missing active-tab scoping
    Review UI dismissal or presentation logic that lacks an owner-WebState record (like _presentedWebState) to confirm the event originates from the tab currently presenting.
From ac619336ac2ba8c3d85c6a3d1862790a77d81882 Mon Sep 17 00:00:00 2001
From: Quentin Pubert <qpubert@google.com>
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 <qpubert@google.com>
Reviewed-by: Olivier Robin <olivierrobin@chromium.org>
Commit-Queue: Quentin Pubert <qpubert@google.com>
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.