Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect security UI in Chrome for iOS
DescriptionIncorrect security UI in Chrome for iOS
ComponentChrome for iOS
Bug ClassLogic Error
Tracker513989973
Fix commite5dcea4a1119 (chromium/src) +358/-28
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
ARQuickLookTabHelper
ios/chrome/browser/download/model/ar_quick_look_tab_helper.h
modified

Files Changed

  • ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm
  • ios/chrome/browser/download/model/ar_quick_look_tab_helper.h
  • ios/chrome/browser/download/model/ar_quick_look_tab_helper.mm
From e5dcea4a11199d00d7d41dca9d29f136b829117d Mon Sep 17 00:00:00 2001
From: Quentin Pubert <[email protected]>
Date: Fri, 22 May 2026 04:13:38 -0700
Subject: [PATCH] [iOS] Defer native UI presentation for background specialized downloads

This CL implements visibility checks in four specialized tab helpers:
- 1) VcardTabHelper
- 2) PassKitTabHelper
- 3) SafariDownloadTabHelper
- 4) ARQuickLookTabHelper

If a download completes or is triggered while the tab is in the
background, its presentation is deferred. The latest pending download's
native UI (contact card, passes sheet, mobileconfig alert, or AR model
preview) is presented immediately once the tab is brought back to the
foreground (receiving the WasShown observer callback). To prevent a
flood of multiple modal UI sheet overlays when switching back, only the
most recent pending download is kept per tab helper.

Bug: 513989973
Change-Id: Ifc21e86fb784fccb0a1ee0ae70b5a8aa7e5655b0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7859853
Auto-Submit: Quentin Pubert <[email protected]>
Commit-Queue: Quentin Pubert <[email protected]>
Reviewed-by: Olivier Robin <[email protected]>
Commit-Queue: Olivier Robin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1634855}
---

diff --git a/ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm b/ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm
index 70bdadf..e2c71c2 100644
--- a/ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm
+++ b/ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm
@@ -20,6 +20,7 @@
 #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 "net/base/apple/url_conversions.h"
@@ -83,6 +84,7 @@
     // SafariDownloadTabHelper instances once started.
     auto web_state = std::make_unique<web::FakeWebState>();
     auto* web_state_ptr = web_state.get();
+    web_state_ptr->WasShown();
     SafariDownloadTabHelper::CreateForWebState(web_state_ptr);
     browser_->GetWebStateList()->InsertWebState(std::move(web_state));
     [coordinator_ start];
@@ -241,4 +243,33 @@
       0);
 }
 
+// Tests that SafariDownloadTabHelper defers UI alert presentation when the
+// WebState is hidden.
+TEST_F(SafariDownloadCoordinatorTest,
+       DeferSafariDownloadPresentationWhenHidden) {
+  web::FakeWebState* fake_web_state = static_cast<web::FakeWebState*>(
+      browser_->GetWebStateList()->GetWebStateAt(0));
+  fake_web_state->WasHidden();
+
+  auto task = std::make_unique<web::FakeDownloadTask>(
+      GURL("https://test.test/mobileconfig"), kMobileConfigurationType);
+
+  // Start the download in tab helper.
+  tab_helper()->DownloadMobileConfig(std::move(task));
+
+  // The warning alert should not be presented while hidden.
+  EXPECT_FALSE(WaitUntilConditionOrTimeout(kWaitForUIElementTimeout, ^{
+    return [base_view_controller_.presentedViewController class] ==
+           [UIAlertController class];
+  }));
+
+  // Now show the web state. The warning alert should be presented.
+  fake_web_state->WasShown();
+
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForUIElementTimeout, ^{
+    return [base_view_controller_.presentedViewController class] ==
+           [UIAlertController class];
+  }));
+}
+
 }  // namespace
diff --git a/ios/chrome/browser/download/model/ar_quick_look_tab_helper.h b/ios/chrome/browser/download/model/ar_quick_look_tab_helper.h
index 56b543ca..f2a17b2 100644
--- a/ios/chrome/browser/download/model/ar_quick_look_tab_helper.h
+++ b/ios/chrome/browser/download/model/ar_quick_look_tab_helper.h
@@ -5,11 +5,14 @@
 #ifndef IOS_CHROME_BROWSER_DOWNLOAD_MODEL_AR_QUICK_LOOK_TAB_HELPER_H_
 #define IOS_CHROME_BROWSER_DOWNLOAD_MODEL_AR_QUICK_LOOK_TAB_HELPER_H_
 
-#include <memory>
+#import <memory>
+#import <optional>
 
-#include "base/memory/raw_ptr.h"
-#include "ios/web/public/download/download_task_observer.h"
-#include "ios/web/public/web_state_user_data.h"
+#import "base/memory/raw_ptr.h"
+#import "base/scoped_observation.h"
+#import "ios/web/public/download/download_task_observer.h"
+#import "ios/web/public/web_state_observer.h"
+#import "ios/web/public/web_state_user_data.h"
 
 @protocol ARQuickLookTabHelperDelegate;
 
@@ -48,6 +51,7 @@
 // TabHelper to download and preview USDZ format 3D models for AR.
 class ARQuickLookTabHelper
     : public web::DownloadTaskObserver,
+      public web::WebStateObserver,
       public web::WebStateUserData<ARQuickLookTabHelper> {
  public:
   ARQuickLookTabHelper(const ARQuickLookTabHelper&) = delete;
@@ -80,15 +84,32 @@
   // web::DownloadTaskObserver:
   void OnDownloadUpdated(web::DownloadTask* download_task) override;
 
+  // web::WebStateObserver overrides:
+  void WasShown(web::WebState* web_state) override;
+
   // Previews the downloaded USDZ file or confirms the download if download has
   // not started.
   void ConfirmOrPreviewDownload(web::DownloadTask* download_task);
 
+  // Structure to hold the metadata of an AR Quick Look preview that completed
+  // while the web state was hidden, allowing it to be deferred.
+  struct PendingARPreview {
+    NSURL* file_url;
+    NSURL* canonical_url;
+    bool allow_content_scaling;
+  };
+
   raw_ptr<web::WebState> web_state_ = nullptr;
   __weak id<ARQuickLookTabHelperDelegate> delegate_ = nil;
 
   // The current download task.
   std::unique_ptr<web::DownloadTask> download_task_;
+
+  // The preview that completed while the tab was hidden.
+  std::optional<PendingARPreview> pending_preview_;
+
+  base::ScopedObservation<web::WebState, web::WebStateObserver>
+      web_state_observation_{this};
 };
 
 #endif  // IOS_CHROME_BROWSER_DOWNLOAD_MODEL_AR_QUICK_LOOK_TAB_HELPER_H_
diff --git a/ios/chrome/browser/download/model/ar_quick_look_tab_helper.mm b/ios/chrome/browser/download/model/ar_quick_look_tab_helper.mm
index c5ae860..562c018 100644
--- a/ios/chrome/browser/download/model/ar_quick_look_tab_helper.mm
+++ b/ios/chrome/browser/download/model/ar_quick_look_tab_helper.mm
@@ -104,9 +104,12 @@
 
 }  // namespace
 
+#pragma mark - Initialization
+
 ARQuickLookTabHelper::ARQuickLookTabHelper(web::WebState* web_state)
     : web_state_(web_state) {
-  DCHECK(web_state_);
+  CHECK(web_state_);
+  web_state_observation_.Observe(web_state);
 }
 
 ARQuickLookTabHelper::~ARQuickLookTabHelper() {
@@ -115,6 +118,8 @@
   }
 }
 
+#pragma mark - Public
+
 void ARQuickLookTabHelper::Download(
     std::unique_ptr<web::DownloadTask> download_task) {
   DCHECK(download_task);
@@ -197,10 +202,14 @@
 
   NSURL* file_url =
       base::apple::FilePathToNSURL(download_task_->GetResponsePath());
-  [delegate_ presentUSDZFileWithURL:file_url
-                       canonicalURL:canonical_url
-                           webState:web_state_
-                allowContentScaling:allow_content_scaling];
+  if (web_state_->IsVisible()) {
+    [delegate_ presentUSDZFileWithURL:file_url
+                         canonicalURL:canonical_url
+                             webState:web_state_
+                  allowContentScaling:allow_content_scaling];
+  } else {
+    pending_preview_ = {file_url, canonical_url, allow_content_scaling};
+  }
 }
 
 void ARQuickLookTabHelper::RemoveCurrentDownload() {
@@ -208,6 +217,8 @@
   download_task_.reset();
 }
 
+#pragma mark - DownloadTaskObserver
+
 void ARQuickLookTabHelper::OnDownloadUpdated(web::DownloadTask* download_task) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm b/ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm
index 70bdadf..e2c71c2 100644
--- a/ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm
+++ b/ios/chrome/browser/download/coordinator/safari_download_coordinator_unittest.mm
@@ -20,6 +20,7 @@
 #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 "net/base/apple/url_conversions.h"
@@ -83,6 +84,7 @@
     // SafariDownloadTabHelper instances once started.
     auto web_state = std::make_unique<web::FakeWebState>();
     auto* web_state_ptr = web_state.get();
+    web_state_ptr->WasShown();
     SafariDownloadTabHelper::CreateForWebState(web_state_ptr);
     browser_->GetWebStateList()->InsertWebState(std::move(web_state));
     [coordinator_ start];
@@ -241,4 +243,33 @@
       0);
 }
 
+// Tests that SafariDownloadTabHelper defers UI alert presentation when the
+// WebState is hidden.
+TEST_F(SafariDownloadCoordinatorTest,
+       DeferSafariDownloadPresentationWhenHidden) {
+  web::FakeWebState* fake_web_state = static_cast<web::FakeWebState*>(
+      browser_->GetWebStateList()->GetWebStateAt(0));
+  fake_web_state->WasHidden();
+
+  auto task = std::make_unique<web::FakeDownloadTask>(
+      GURL("https://test.test/mobileconfig"), kMobileConfigurationType);
+
+  // Start the download in tab helper.
+  tab_helper()->DownloadMobileConfig(std::move(task));
+
+  // The warning alert should not be presented while hidden.
+  EXPECT_FALSE(WaitUntilConditionOrTimeout(kWaitForUIElementTimeout, ^{
+    return [base_view_controller_.presentedViewController class] ==
+           [UIAlertController class];
+  }));
+
+  // Now show the web state. The warning alert should be presented.
+  fake_web_state->WasShown();
+
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForUIElementTimeout, ^{
+    return [base_view_controller_.presentedViewController class] ==
+           [UIAlertController class];
+  }));
+}
+
 }  // namespace
diff --git a/ios/chrome/browser/download/model/ar_quick_look_tab_helper_unittest.mm b/ios/chrome/browser/download/model/ar_quick_look_tab_helper_unittest.mm
index a73abfa..44acc3e3 100644
--- a/ios/chrome/browser/download/model/ar_quick_look_tab_helper_unittest.mm
+++ b/ios/chrome/browser/download/model/ar_quick_look_tab_helper_unittest.mm
@@ -48,6 +48,7 @@
   ARQuickLookTabHelperTest()
       : delegate_([[FakeARQuickLookTabHelperDelegate alloc] init]) {
     ARQuickLookTabHelper::CreateForWebState(&web_state_);
+    web_state_.WasShown();
     ARQuickLookTabHelper::FromWebState(&web_state_)->set_delegate(delegate_);
   }
 
@@ -59,9 +60,10 @@
 
   base::HistogramTester* histogram_tester() { return &histogram_tester_; }
 
+  web::FakeWebState web_state_;
+
  private:
   web::WebTaskEnvironment task_environment_;
-  web::FakeWebState web_state_;
   FakeARQuickLookTabHelperDelegate* delegate_;
   base::HistogramTester histogram_tester_;
 };
@@ -546,6 +548,33 @@
       1);
 }
 
+// Tests deferring AR model preview when the tab is hidden.
+TEST_F(ARQuickLookTabHelperTest, DeferARPreviewWhenHidden) {
+  web_state_.WasHidden();
+
+  auto task = std::make_unique<web::FakeDownloadTask>(GURL(kUrl), "other");
+  task->SetGeneratedFileName(base::FilePath(kTestUsdzFileName));
+  web::FakeDownloadTask* task_ptr = task.get();
+
+  {
+    web::test::WaitDownloadTaskUpdated observer(task_ptr);
+    tab_helper()->Download(std::move(task));
+    observer.Wait();
+  }
+
+  task_ptr->SetDone(true);
+
+  // The delegate should not be notified while hidden.
+  EXPECT_EQ(0U, delegate().fileURLs.count);
+
+  // Now, show the web state. The delegate should be notified.
+  web_state_.WasShown();
+  EXPECT_EQ(1U, delegate().fileURLs.count);
+  EXPECT_TRUE([delegate().fileURLs.firstObject isKindOfClass:[NSURL class]]);
+  EXPECT_TRUE(delegate().allowsContentScaling);
+  EXPECT_FALSE(delegate().canonicalWebPageURL);
+}
+
 INSTANTIATE_TEST_SUITE_P(,
                          ARQuickLookTabHelperTest,
                          ::testing::Values(kUsdzMimeType,
diff --git a/ios/chrome/browser/download/model/pass_kit_tab_helper_unittest.mm b/ios/chrome/browser/download/model/pass_kit_tab_helper_unittest.mm
index dfbb379b8..a1f78e94 100644
--- a/ios/chrome/browser/download/model/pass_kit_tab_helper_unittest.mm
+++ b/ios/chrome/browser/download/model/pass_kit_tab_helper_unittest.mm
@@ -36,6 +36,7 @@
  protected:
   PassKitTabHelperTest() : handler_([[FakeWebContentHandler alloc] init]) {
     PassKitTabHelper::CreateForWebState(&web_state_);
+    web_state_.WasShown();
     PassKitTabHelper::FromWebState(&web_state_)
         ->SetWebContentsHandler(handler_);
   }
@@ -323,3 +324,38 @@
           DownloadPassKitResult::kUnauthorizedFailure),
       1);
 }
+
+// Tests deferring PassKit presentation when the tab is hidden.
+TEST_F(PassKitTabHelperTest, DeferPassKitPresentationWhenHidden) {
+  web_state_.WasHidden();
+
+  auto task =
+      std::make_unique<web::FakeDownloadTask>(GURL(kUrl), kPkPassMimeType);
+  web::FakeDownloadTask* task_ptr = task.get();
+  tab_helper()->Download(std::move(task));
+
+  std::string pass_data =
+      testing::GetTestFileContents(testing::kPkPassFilePath);
+  NSData* data = [NSData dataWithBytes:pass_data.data()
+                                length:pass_data.size()];
+  task_ptr->SetResponseData(data);
+  task_ptr->SetDone(true);
+
+  // The dialog should not be shown while the web state is hidden.
+  EXPECT_EQ(0U, handler_.passes.count);
+
+  // Show the web state. The dialog should be shown.
+  web_state_.WasShown();
+  EXPECT_EQ(1U, handler_.passes.count);
+  PKPass* pass = handler_.passes.firstObject;
+  EXPECT_TRUE([pass isKindOfClass:[PKPass class]]);
+  EXPECT_EQ(PKPassTypeBarcode, pass.passType);
+  EXPECT_NSEQ(@"pass.com.apple.devpubs.example", pass.passTypeIdentifier);
+  EXPECT_NSEQ(@"Toy Town", pass.organizationName);
+
+  histogram_tester_.ExpectUniqueSample(
+      kUmaDownloadPassKitResult,
+      static_cast<base::HistogramBase::Sample32>(
+          DownloadPassKitResult::kSuccessful),
+      1);
+}
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 15d69dc0..e3fb624 100644
--- a/ios/chrome/browser/download/model/vcard_tab_helper_unittest.mm
+++ b/ios/chrome/browser/download/model/vcard_tab_helper_unittest.mm
@@ -27,7 +27,10 @@
 // Test fixture for testing VcardTabHelperTest class.
 class VcardTabHelperTest : public PlatformTest {
  protected:
-  VcardTabHelperTest() { VcardTabHelper::CreateForWebState(&web_state_); }
+  VcardTabHelperTest() {
+    VcardTabHelper::CreateForWebState(&web_state_);
+    web_state_.WasShown();
+  }
 
   VcardTabHelper* tab_helper() {
     return VcardTabHelper::FromWebState(&web_state_);
@@ -79,3 +82,37 @@
 
   EXPECT_OCMOCK_VERIFY(mockHandler);
 }
+
+// Tests deferring vcard presentation when the tab is hidden.
+TEST_F(VcardTabHelperTest, DeferVcardPresentationWhenHidden) {
+  web_state_.WasHidden();
+
+  auto task =
+      std::make_unique<web::FakeDownloadTask>(GURL(kUrl), kVcardMimeType);
+  web::FakeDownloadTask* task_ptr = task.get();
+  tab_helper()->Download(std::move(task));
+
+  std::string pass_data = testing::GetTestFileContents(testing::kVcardFilePath);
+  NSData* data = [NSData dataWithBytes:pass_data.data()
+                                length:pass_data.size()];
+
+  id mock_handler = OCMProtocolMock(@protocol(VcardTabHelperDelegate));
+  tab_helper()->set_delegate(mock_handler);
+
+  // The delegate should not be notified while the web state is hidden.
+  [[mock_handler reject] openVcardFromData:OCMOCK_ANY];
+
+  task_ptr->SetResponseData(data);
+  task_ptr->SetDone(true);
+
+  EXPECT_OCMOCK_VERIFY(mock_handler);
+
+  // Now, show the web state. The delegate should be called.
+  id mock_handler_visible = OCMProtocolMock(@protocol(VcardTabHelperDelegate));
+  tab_helper()->set_delegate(mock_handler_visible);
+  OCMExpect([mock_handler_visible openVcardFromData:data]);
+
+  web_state_.WasShown();
+
+  EXPECT_OCMOCK_VERIFY(mock_handler_visible);
+}
Loading diff…

Original Bug Report

reported by [email protected]

Potential UI spoofing via background download completion in specialized coordinators on iOS

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: Specialized download coordinators for vCards, PassKit, and AR models on iOS fail to verify if the initiating tab is active before presenting native UI. This allows a background tab to overlay modal system sheets or alerts over an unrelated foreground tab. This behavior can be leveraged for origin spoofing and social engineering.

Affected files:

  • ios/chrome/browser/download/coordinator/vcard_coordinator.mm
  • ios/chrome/browser/download/model/vcard_tab_helper.mm
  • ios/chrome/browser/download/coordinator/pass_kit_coordinator.mm
  • ios/chrome/browser/download/model/pass_kit_tab_helper.mm
  • ios/chrome/browser/download/coordinator/safari_download_coordinator.mm
  • ios/chrome/browser/download/model/safari_download_tab_helper.mm
  • ios/chrome/browser/download/model/browser_download_service.mm

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

Several specialized download handlers in Chrome for iOS lack a visibility check when presenting native UI upon download completion. If a download is initiated in one tab and completes after the user has switched to another, the resulting native system modal (e.g., a contact card, Wallet pass, or AR preview) will appear over the current foreground tab without any indication of its origin.

Technical Details

When a download for specific MIME types (vCard, PassKit, USDZ, etc.) completes, specialized coordinators are used to present the content using native iOS components. These coordinators and their associated tab helpers do not currently check if the initiating WebState is visible.

Affected components include:

  • VcardCoordinator: Presents CNContactViewController for vCard files.
  • PassKitCoordinator: Presents PKAddPassesViewController for .pkpass files.
  • SafariDownloadCoordinator: Presents UIAlertController for .mobileconfig, .ics, and Wallet order files.
  • ARQuickLookCoordinator: Presents QLPreviewController for USDZ files.

The root cause is the absence of a web_state_->IsVisible() check in the flow between the Tab Helper (e.g., VcardTabHelper) and its Coordinator delegate. Because the baseViewController used for presentation is the shared BrowserViewController, the UI is displayed to the user regardless of which tab is active.

In contrast, the generic DownloadManagerTabHelper correctly implements this check in DidCreateDownload and WasShown, ensuring UI is only presented for active tabs.

Potential Impact

This issue facilitates UI and Origin spoofing. An attacker can initiate a download in a background tab and time its completion (potentially by throttling the server response) so that a native modal appears over a trusted site in the foreground. Since these are native system sheets overlaying the browser UI, users may incorrectly attribute the prompt to the site they are currently viewing, leading to potential phishing or social engineering attacks (e.g., presenting a fraudulent ‘Bank Support’ contact card while the user is on a legitimate banking site).

Suggested Reproduction Steps (Potential)

  1. Open a potential attacker-controlled page in Tab B.
  2. Trigger a download for a vCard file (e.g., attacker.test/fake_contact.vcf) where the server response is throttled to provide a delay.
  3. Switch to Tab A (e.g., google.com or bank.com).
  4. Wait for the download to complete in background Tab B.
  5. Observe if the native ‘Unknown Contact’ sheet appears while Tab A is in the foreground, with no indicator of its origin from Tab B.

Suggested Fix

Implement visibility checks in the affected Tab Helpers (VcardTabHelper, PassKitTabHelper, SafariDownloadTabHelper, and ARQuickLookTabHelper) similar to the logic in DownloadManagerTabHelper. Presentation of the UI should be deferred until the initiating WebState becomes visible. If a download completes while the tab is hidden, the Tab Helper should wait for the WasShown observer notification before notifying its coordinator delegate.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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