Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Chrome for iOS
DescriptionInappropriate implementation in Chrome for iOS
ComponentChrome for iOS
Bug ClassLogic Error
Tracker518080978
Fix commit04f754576142 (chromium/src) +78/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.mm
modified
TEST_F
ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
modified
BindOnce
ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
modified

Files Changed

  • ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.h
  • ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.mm
  • ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
From 04f75457614211264dc142a21265ab212be9d463 Mon Sep 17 00:00:00 2001
From: Quentin Pubert <qpubert@google.com>
Date: Tue, 23 Jun 2026 02:09:41 -0700
Subject: [PATCH] [iOS] Reset last ChooseFileEvent when a navigation commits

ChooseFileTabHelper clears `last_choose_file_event_` in
DidStartNavigation, but the outgoing document can keep running script
while the navigation is pending and set a new event before the new
document commits. That event would then be consumed by RunOpenPanel for
the newly committed document.

Override DidFinishNavigation and reset the stored event again for
committed cross-document navigations so it cannot leak across documents.
Same-document and uncommitted navigations leave the event untouched.

Fixed: 518080978
Change-Id: Ife977488b06719212a3c22b722530cfc621d50bc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7978537
Auto-Submit: Quentin Pubert <qpubert@google.com>
Commit-Queue: Quentin Pubert <qpubert@google.com>
Reviewed-by: Olivier Robin <olivierrobin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1650865}
---

diff --git a/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.h b/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.h
index 2f10e72..36f36cf 100644
--- a/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.h
+++ b/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.h
@@ -93,6 +93,8 @@
   // web::WebStateObserver implementation.
   void DidStartNavigation(web::WebState* web_state,
                           web::NavigationContext* navigation_context) override;
+  void DidFinishNavigation(web::WebState* web_state,
+                           web::NavigationContext* navigation_context) override;
   void WasHidden(web::WebState* web_state) override;
   void WebStateDestroyed(web::WebState* web_state) override;
 
@@ -129,6 +131,9 @@
   // Latest `ChooseFileEvent` received from JavaScript.
   std::optional<ChooseFileEvent> last_choose_file_event_;
 
+  // Whether a cross-document navigation is currently pending.
+  bool is_pending_navigation_ = false;
+
   // Handler to show/hide the file upload panel UI.
   __weak id<FileUploadPanelCommands> file_upload_panel_handler_ = nil;
 
diff --git a/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.mm b/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.mm
index 33c1ff3..ac6d041c 100644
--- a/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.mm
+++ b/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.mm
@@ -91,9 +91,11 @@
   CHECK(base::FeatureList::IsEnabled(kIOSCustomFileUploadMenu));
 
   web::WebState* web_state = observation_.GetSource();
-  if (!web_state || web_state->IsBeingDestroyed() || !web_state->IsVisible()) {
+  if (!web_state || web_state->IsBeingDestroyed() || !web_state->IsVisible() ||
+      is_pending_navigation_) {
     // If there is no WebState anymore, or it is being destroyed or not shown,
-    // then call the completion with no selection and return.
+    // or a navigation is pending, then call the completion with no selection
+    // and return.
     std::move(completion).Run(nil);
     return;
   }
@@ -157,6 +159,9 @@
 }
 
 void ChooseFileTabHelper::SetLastChooseFileEvent(ChooseFileEvent event) {
+  if (is_pending_navigation_) {
+    return;
+  }
   last_choose_file_event_ = std::move(event);
 }
 
@@ -230,11 +235,27 @@
     web::WebState* web_state,
     web::NavigationContext* navigation_context) {
   if (!navigation_context->IsSameDocument()) {
+    is_pending_navigation_ = true;
     AbortSelection();
     ResetLastChooseFileEvent();
   }
 }
 
+void ChooseFileTabHelper::DidFinishNavigation(
+    web::WebState* web_state,
+    web::NavigationContext* navigation_context) {
+  if (navigation_context->IsSameDocument()) {
+    return;
+  }
+  is_pending_navigation_ = false;
+  if (navigation_context->HasCommitted()) {
+    // The outgoing document can keep running script after `DidStartNavigation`
+    // and may set a new event before this navigation commits. Reset the event
+    // again so it is not associated with the newly committed document.
+    ResetLastChooseFileEvent();
+  }
+}
+
 void ChooseFileTabHelper::WasHidden(web::WebState* web_state) {
   AbortSelection();
   ResetLastChooseFileEvent();
diff --git a/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm b/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
index aff35102..619fb27b 100644
--- a/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
+++ b/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
@@ -217,6 +217,56 @@
   EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
 }
 
+// Tests that file events and panel presentation requests are ignored while a
+// cross-document navigation is pending across documents.
+TEST_F(ChooseFileTabHelperTest, PendingNavigationIgnoresChooseFileEvent) {
+  EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
+
+  ChooseFileEvent event = ChooseFileEvent::Builder()
+                              .SetAllowMultipleFiles(false)
+                              .SetHasSelectedFile(false)
+                              .SetWebState(web_state_.get())
+                              .Build();
+
+  auto navigation_context = std::make_unique<web::FakeNavigationContext>();
+  navigation_context->SetIsSameDocument(false);
+  tab_helper_->DidStartNavigation(web_state_.get(), navigation_context.get());
+  EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
+
+  // The outgoing document attempts to set a new event while the navigation is
+  // pending. It should be ignored.
+  tab_helper_->SetLastChooseFileEvent(event);
+  EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
+
+  if (@available(iOS 18.4, *)) {
+    base::test::ScopedFeatureList feature_list;
+    feature_list.InitAndEnableFeature(kIOSCustomFileUploadMenu);
+
+    id parameters = [OCMockObject mockForClass:[WKOpenPanelParameters class]];
+    __block bool completion_called = false;
+    tab_helper_->RunOpenPanel(parameters, /*frame=*/nil,
+                              base::BindOnce(^(NSArray<NSURL*>* result_urls) {
+                                completion_called = true;
+                                EXPECT_NSEQ(nil, result_urls);
+                              }));
+    EXPECT_TRUE(completion_called);
+    EXPECT_FALSE(tab_helper_->IsChoosingFiles());
+  }
+
+  // Uncommitted cross-document navigation finishes. Storing events should work
+  // again.
+  navigation_context->SetHasCommitted(false);
+  tab_helper_->DidFinishNavigation(web_state_.get(), navigation_context.get());
+  tab_helper_->SetLastChooseFileEvent(event);
+  EXPECT_TRUE(tab_helper_->HasLastChooseFileEvent());
+
+  // Committed cross-document navigation should reset last_choose_file_event_.
+  navigation_context->SetIsSameDocument(false);
+  navigation_context->SetHasCommitted(true);
+  tab_helper_->DidStartNavigation(web_state_.get(), navigation_context.get());
+  EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
+}
+
 // Tests that hiding the tab resets the last ChooseFileEvent.
 TEST_F(ChooseFileTabHelperTest, WasHiddenResetsLastChooseFileEvent) {
   EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm b/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
index aff35102..619fb27b 100644
--- a/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
+++ b/ios/chrome/browser/web/model/choose_file/choose_file_tab_helper_unittest.mm
@@ -217,6 +217,56 @@
   EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
 }
 
+// Tests that file events and panel presentation requests are ignored while a
+// cross-document navigation is pending across documents.
+TEST_F(ChooseFileTabHelperTest, PendingNavigationIgnoresChooseFileEvent) {
+  EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
+
+  ChooseFileEvent event = ChooseFileEvent::Builder()
+                              .SetAllowMultipleFiles(false)
+                              .SetHasSelectedFile(false)
+                              .SetWebState(web_state_.get())
+                              .Build();
+
+  auto navigation_context = std::make_unique<web::FakeNavigationContext>();
+  navigation_context->SetIsSameDocument(false);
+  tab_helper_->DidStartNavigation(web_state_.get(), navigation_context.get());
+  EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
+
+  // The outgoing document attempts to set a new event while the navigation is
+  // pending. It should be ignored.
+  tab_helper_->SetLastChooseFileEvent(event);
+  EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
+
+  if (@available(iOS 18.4, *)) {
+    base::test::ScopedFeatureList feature_list;
+    feature_list.InitAndEnableFeature(kIOSCustomFileUploadMenu);
+
+    id parameters = [OCMockObject mockForClass:[WKOpenPanelParameters class]];
+    __block bool completion_called = false;
+    tab_helper_->RunOpenPanel(parameters, /*frame=*/nil,
+                              base::BindOnce(^(NSArray<NSURL*>* result_urls) {
+                                completion_called = true;
+                                EXPECT_NSEQ(nil, result_urls);
+                              }));
+    EXPECT_TRUE(completion_called);
+    EXPECT_FALSE(tab_helper_->IsChoosingFiles());
+  }
+
+  // Uncommitted cross-document navigation finishes. Storing events should work
+  // again.
+  navigation_context->SetHasCommitted(false);
+  tab_helper_->DidFinishNavigation(web_state_.get(), navigation_context.get());
+  tab_helper_->SetLastChooseFileEvent(event);
+  EXPECT_TRUE(tab_helper_->HasLastChooseFileEvent());
+
+  // Committed cross-document navigation should reset last_choose_file_event_.
+  navigation_context->SetIsSameDocument(false);
+  navigation_context->SetHasCommitted(true);
+  tab_helper_->DidStartNavigation(web_state_.get(), navigation_context.get());
+  EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
+}
+
 // Tests that hiding the tab resets the last ChooseFileEvent.
 TEST_F(ChooseFileTabHelperTest, WasHiddenResetsLastChooseFileEvent) {
   EXPECT_FALSE(tab_helper_->HasLastChooseFileEvent());
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential iOS ChooseFileTabHelper bypass allows cross-origin spoofing of file-picker attributes

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: A potential cross-origin state contamination vulnerability in Chrome for iOS allows an outgoing page to re-seed the file picker event state during provisional navigation. When a newly loaded victim page subsequently triggers a file picker that bypasses the typescript hook, the browser consumes the attacker’s spoofed attributes. This enables an attacker to potentially control file-picker attributes such as MIME filters, camera activation, and anchoring coordinates on behalf of the victim origin.

Affected files:

  • ios/chrome/browser/web/model/choose_file/choose_file_tab_helper.mm
  • ios/chrome/browser/web/model/choose_file/choose_file_java_script_feature.mm
  • ios/chrome/browser/file_upload_panel/coordinator/file_upload_panel_mediator.mm
  • ios/chrome/browser/web/model/choose_file/resources/choose_file.ts

Estimated timestamp from git blame: 2025-10-03

Root Cause Analysis

ChooseFileTabHelper maintains a single per-tab last_choose_file_event_ slot to pass configuration parameters (MIME types, file extensions, camera activation, and screen coordinate anchors) from the page-world JavaScript click handler to the native iOS file-picker coordinator.

However, this slot is only cleared at the start of navigation via DidStartNavigation:

void ChooseFileTabHelper::DidStartNavigation(
    web::WebState* web_state,
    web::NavigationContext* navigation_context) {
  if (!navigation_context->IsSameDocument()) {
    AbortSelection();
    ResetLastChooseFileEvent();
  }
}

On iOS, WebStateObserver::DidStartNavigation is dispatched when a provisional navigation starts (-webView:didStartProvisionalNavigation:). At this point, the outgoing document is still active and executing in the WKWebView until the navigation commits (-webView:didCommitNavigation:), which typically takes at least one network round-trip time (RTT).

During this provisional window, the outgoing (attacker) page can execute script and post messages directly to the registered ChooseFileHandler script message handler. Because ChooseFileJavaScriptFeature handles incoming script messages without checking if the frame is in a provisional state, the attacker can successfully re-seed the last_choose_file_event_ slot after the initial reset has occurred.

When the navigation finally commits and loads the victim page, the seeded last_choose_file_event_ remains in the slot. If the victim page subsequently opens its <input type=file> via a path that bypasses the page-world choose_file.ts hook (such as an input inside the Shadow DOM, HTMLInputElement.showPicker(), or when capture-phase event propagation is stopped), the victim does not overwrite the seeded event.

Consequently, when WebKit calls the open panel delegate, ChooseFileTabHelper::RunOpenPanel pops the attacker’s event from the slot and ignores the trusted WKFrameInfo* frame argument, consuming the attacker-controlled accept_file_extensions, accept_mime_types, capture, and screen_location properties on behalf of the victim origin.

Downstream Impact

An attacker can potentially spoof the file-picker attributes presented on behalf of a victim origin, leading to native UI misrepresentation and origin confusion:

  • Camera Enlistment/Hijacking: Force a full-screen camera view to open directly instead of the document/file picker, or vice-versa.
  • Filter Manipulation: Restrict or expand the UTType filters shown in the native document picker (e.g. limiting the user’s choice to certificate types like .p12).
  • Coordinate Anchoring Spoofing: Position the native presentation popover/context menu at arbitrary screen coordinates (e.g. over the omnibox or other sensitive browser chrome) to aid in social engineering or clickjacking.

Suggested/Potential Exploitation Steps

(Note: These are potential steps; our tooling does not currently have the capability to run code or execute live proofs-of-concept on iOS)

  1. The user visits an attacker-controlled page (https://attacker.example). The page executes an interval to repeatedly post message events and navigates the tab to a victim page:
    setInterval(() => {
      webkit.messageHandlers.ChooseFileHandler.postMessage({
        acceptType: 0, 
        hasMultiple: false, 
        hasWebkitdirectory: false,
        hasSelectedFile: false, 
        capture: 1, 
        fileExtensions: '.p12',
        mimeTypes: 'application/x-pkcs12',
        screenLocation: { x: 200, y: 40 },
        pointerType: 'touch'
      });
    }, 20);
    location.href = 'https://victim.example/upload';
    
  2. The web view initiates navigation and fires didStartProvisionalNavigation:, which clears last_choose_file_event_ via DidStartNavigation in the browser process.
  3. The attacker’s interval fires again before the victim page commits, successfully writing the malicious properties back into last_choose_file_event_ via the script message handler.
  4. The victim page commits and opens its file input via a path that bypasses the hook (e.g., calling input.showPicker()), leaving the attacker-seeded slot intact.
  5. The file picker opens using the attacker’s properties, forcing the camera to open or restricting document selection to certificate files instead of those requested by the victim.

Suggested Fix

To remediate this issue, consider implementing the following protections:

  1. State Isolation: Instead of a single tab-wide last_choose_file_event_ slot, associate the seeded events with the specific frame/document ID that generated them, or validate that the origin of the frame passed via WKFrameInfo* frame in RunOpenPanel strictly matches the security origin of the frame that generated the ChooseFileEvent.
  2. Commit-time Clearance: Clear the last_choose_file_event_ slot on navigation commit (e.g. within DidFinishNavigation or DidCommitNavigation) to ensure that state from a previous document is discarded upon loading a new document.

Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040


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