Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in FileSystem
DescriptionMissing authorization in FileSystem
ComponentFileSystem
Sub-componentBlink>Storage>FileSystem
Bug ClassAuthorization Error
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.
Tracker502411391
Fix commit386998126bfe (chromium/src) +317/-350
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-14 (per vendor advisory)
Fix landed2026-08-26 (public git)
Disclosed2026-09-01
Patch gap6 days — fix public in source before the advisory

Background

File System Access API
the web platform interface (showOpenFilePicker / showDirectoryPicker) that lets an origin prompt the user to grant read/write access to local files and directories.
`FileSystemAccessManagerImpl`
the browser-process class in content/browser/file_system_access/ that services File System Access mojo calls and drives the native chooser dialog.
`WebContents::GetVisibility()`
a browser-side query returning whether a tab is VISIBLE, OCCLUDED, or HIDDEN to the user.
`kOperationAborted`
the FileSystemAccessStatus value returned to the renderer when a request is refused before any picker is shown.

Root Cause Analysis

The vulnerable path is ShowFilePickerOnUIThread in FileSystemAccessManagerImpl, which gates the native file/directory chooser only on web_contents being non-null and outermost_rfh->IsActive() being true, but never checked whether the tab was actually visible to the user. Because a frame can remain “active” (its RenderFrameHost is a current, non-BFCache primary frame) while its WebContents is HIDDEN — for example a background tab or an occluded window — an origin could invoke showOpenFilePicker/showDirectoryPicker and surface the chooser while the user’s attention was on a different, foreground surface. This violates the invariant that a security-sensitive, user-consent prompt must originate from and be attributable to the visible page that requested it.

The fix adds a web_contents->GetVisibility() == Visibility::HIDDEN check (guarded by the kFileSystemAccessCheckHidden feature flag) to the same guard clause, so a hidden WebContents now short-circuits to kOperationAborted before the dialog is ever created. This works because visibility is evaluated in the browser process at the moment of the request, closing the window in which a non-foreground origin could raise the picker.

Key insight
The single mistake was treating frame “active” state as a sufficient proxy for “the user is looking at this page,” when an active frame can belong to a hidden tab; the fix adds an explicit visibility check so the consent-bearing chooser is only shown for a VISIBLE (non-hidden) WebContents.

Attack Path

  1. Prime a background origin A malicious or compromised origin runs in a tab or window that is not the foreground surface, e.g. a background tab, an occluded window, or a just-backgrounded page whose RenderFrameHost is still IsActive().
  2. Trigger the picker while hidden Script calls showOpenFilePicker or showDirectoryPicker, reaching ShowFilePickerOnUIThread even though WebContents::GetVisibility() is HIDDEN.
  3. Bypass the stale guard The pre-fix guard passes because web_contents is non-null and outermost_rfh->IsActive() is true, so the native chooser is created despite the tab being hidden.
  4. Exploit user misattribution The chooser appears without a clearly visible originating page, increasing the chance the user grants file or directory access to an origin they did not knowingly interact with.

Impact Assessment

An attacker gains the ability to raise a local file/directory access prompt from a non-visible page, undermining the user’s ability to attribute the consent request to a specific, foreground origin and increasing the likelihood of an unintended grant of read/write access to local files. This occurs in the browser process on behalf of a web origin using the File System Access API. Preconditions are that the origin can execute script in a live, still-IsActive() frame whose WebContents is HIDDEN, which requires the user to load attacker-controlled content but not to keep it in the foreground.

Changed Functions

FunctionChangeNotes
TestWebViewWebContentsDelegate
content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
modified
if
content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
modified
TEST_F
content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
modified

Files Changed

  • content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
  • content/browser/file_system_access/features.h
  • content/browser/file_system_access/features.cc
  • content/browser/file_system_access/file_system_access_manager_impl.cc

Audit Directions

  • Visibility vs. activity conflation
    Audit other consent- or dialog-raising browser paths that gate on RenderFrameHost::IsActive() alone and add a WebContents::GetVisibility() check where a prompt must come from the foreground page.
  • Chooser and permission entry points
    Review every caller that reaches a native chooser, permission prompt, or SelectFileDialog from a renderer-initiated request to confirm the requesting surface is currently visible.
  • Feature-flag-gated security fixes
    Track kFileSystemAccessCheckHidden and similar flags to ensure the protective check cannot be disabled to reopen the bypass, and verify parity across platforms including Android/WebView delegates.
From 386998126bfe0cbabf2ea81be1fbd4cd1a34f2c8 Mon Sep 17 00:00:00 2001
From: Joel Hockey <[email protected]>
Date: Wed, 26 Aug 2026 05:48:27 -0700
Subject: [PATCH] Abort file picker if WebContents is hidden

In ShowFilePickerOnUIThread, verify that the WebContents is not hidden
(web_contents->GetVisibility() == Visibility::HIDDEN) before attempting
to show the file picker dialog. If hidden, the request is aborted with
FileSystemAccessStatus::kOperationAborted.

Adds ChooseEntries_HiddenWebContents unit test and additional tests for
WebView which uses a WebContentsDelegate.

Bug: 502411391
Change-Id: I9f103135a69ba132bca7de68d62a1a35e0234102
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8294258
Commit-Queue: Joel Hockey <[email protected]>
Reviewed-by: Fergal Daly <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1686307}
---
diff --git a/content/browser/file_system_access/features.cc b/content/browser/file_system_access/features.cc
index 1aab61d4..8cd0a59e 100644
--- a/content/browser/file_system_access/features.cc
+++ b/content/browser/file_system_access/features.cc
@@ -27,6 +27,9 @@
 BASE_FEATURE(kFileSystemAccessObserverQuotaLimit,
              base::FEATURE_ENABLED_BY_DEFAULT);
 
+// When enabled, check for hidden WebContents before showing chooser.
+BASE_FEATURE(kFileSystemAccessCheckHidden, base::FEATURE_ENABLED_BY_DEFAULT);
+
 // On Linux, the quota limit is found by:
 // 1. Rounding down the system limit (read from
 //    /proc/sys/fs/inotify/max_user_watches) to the nearest
diff --git a/content/browser/file_system_access/features.h b/content/browser/file_system_access/features.h
index 4bf18e59..aa3bf43 100644
--- a/content/browser/file_system_access/features.h
+++ b/content/browser/file_system_access/features.h
@@ -19,6 +19,7 @@
 CONTENT_EXPORT BASE_DECLARE_FEATURE(
     kFileSystemAccessDirectoryIterationBlocklistCheck);
 CONTENT_EXPORT BASE_DECLARE_FEATURE(kFileSystemAccessObserverQuotaLimit);
+CONTENT_EXPORT BASE_DECLARE_FEATURE(kFileSystemAccessCheckHidden);
 CONTENT_EXPORT BASE_DECLARE_FEATURE_PARAM(
     size_t,
     kFileSystemObserverQuotaLimitLinuxBucketSize);
diff --git a/content/browser/file_system_access/file_system_access_manager_impl.cc b/content/browser/file_system_access/file_system_access_manager_impl.cc
index 14e62d7..bd28d6a4 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl.cc
@@ -190,7 +190,10 @@
   WebContents* web_contents = WebContents::FromRenderFrameHost(rfh);
   RenderFrameHost* outermost_rfh = rfh ? rfh->GetOutermostMainFrame() : nullptr;
 
-  if (!web_contents || !outermost_rfh || !outermost_rfh->IsActive()) {
+  if (!web_contents ||
+      (base::FeatureList::IsEnabled(features::kFileSystemAccessCheckHidden) &&
+       web_contents->GetVisibility() == Visibility::HIDDEN) ||
+      !outermost_rfh || !outermost_rfh->IsActive()) {
     std::move(callback).Run(file_system_access_error::FromStatus(
                                 FileSystemAccessStatus::kOperationAborted),
                             {});
diff --git a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
index 78c13353..bbee1ae 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
@@ -37,6 +37,9 @@
 #include "content/browser/file_system_access/fixed_file_system_access_permission_grant.h"
 #include "content/browser/file_system_access/mock_file_system_access_permission_context.h"
 #include "content/public/browser/content_browser_client.h"
+#include "content/public/browser/file_select_listener.h"
+#include "content/public/browser/visibility.h"
+#include "content/public/browser/web_contents_delegate.h"
 #include "content/public/test/browser_task_environment.h"
 #include "content/public/test/test_browser_context.h"
 #include "content/public/test/test_utils.h"
@@ -69,6 +72,10 @@
 #include "ui/shell_dialogs/select_file_dialog.h"
 #include "url/gurl.h"
 
+#if BUILDFLAG(IS_ANDROID)
+#include "third_party/blink/public/mojom/choosers/file_chooser.mojom.h"
+#endif
+
 namespace content {
 
 namespace {
@@ -155,6 +162,34 @@
   bool was_checked_ = false;
 };
 
+#if BUILDFLAG(IS_ANDROID)
+// A WebContentsDelegate that simulates WebView by overriding
+// `UseFileChooserForFileSystemAccess()` and handling `RunFileChooser()`.
+class TestWebViewWebContentsDelegate : public WebContentsDelegate {
+ public:
+  bool UseFileChooserForFileSystemAccess() const override { return true; }
+
+  void RunFileChooser(RenderFrameHost* render_frame_host,
+                      scoped_refptr<FileSelectListener> listener,
+                      const blink::mojom::FileChooserParams& params) override {
+    run_file_chooser_called_ = true;
+    listener->FileSelected(std::move(files_), base::FilePath(), params.mode);
+  }
+
+  void SetFileToSelect(base::FilePath file) {
+    files_.push_back(blink::mojom::FileChooserFileInfo::NewNativeFile(
+        blink::mojom::NativeFileInfo::New(file, std::u16string(),
+                                          std::vector<std::u16string>())));
+  }
+
+  bool run_file_chooser_called() const { return run_file_chooser_called_; }
+
+ private:
+  bool run_file_chooser_called_ = false;
+  std::vector<blink::mojom::FileChooserFileInfoPtr> files_;
+};
+#endif
+
 }  // namespace
 
 using base::test::RunOnceCallback;
@@ -261,11 +296,12 @@
       FileSystemAccessPermissionContext::SensitiveEntryResult result,
       FileSystemAccessPermissionContext::UserAction user_action =
           FileSystemAccessPermissionContext::UserAction::kOpen,
-      testing::ExpectationSet after_expectations = {}) {
+      testing::ExpectationSet after_expectations = {},
+      FileSystemAccessPermissionContext::HandleType handle_type =
+          FileSystemAccessPermissionContext::HandleType::kFile) {
     return EXPECT_CALL(permission_context_,
                        ConfirmSensitiveEntryAccess_(
-                           kTestStorageKey.origin(), path_info,
-                           FileSystemAccessPermissionContext::HandleType::kFile,
+                           kTestStorageKey.origin(), path_info, handle_type,
                            user_action,
                            web_contents_->GetPrimaryMainFrame()->GetGlobalId(),
                            testing::_))
@@ -276,25 +312,71 @@
   void ExpectGetReadPermissionGrant(
       const PathInfo& path_info,
       FileSystemAccessPermissionContext::UserAction user_action =
-          FileSystemAccessPermissionContext::UserAction::kOpen) {
-    EXPECT_CALL(
-        permission_context_,
-        GetReadPermissionGrant(
-            kTestStorageKey.origin(), path_info,
-            FileSystemAccessPermissionContext::HandleType::kFile, user_action))
+          FileSystemAccessPermissionContext::UserAction::kOpen,
+      FileSystemAccessPermissionContext::HandleType handle_type =
+          FileSystemAccessPermissionContext::HandleType::kFile) {
+    EXPECT_CALL(permission_context_,
+                GetReadPermissionGrant(kTestStorageKey.origin(), path_info,
+                                       handle_type, user_action))
         .WillOnce(testing::Return(allow_grant_));
   }
 
   void ExpectGetWritePermissionGrant(
       const PathInfo& path_info,
       FileSystemAccessPermissionContext::UserAction user_action =
-          FileSystemAccessPermissionContext::UserAction::kOpen) {
-    EXPECT_CALL(
-        permission_context_,
-        GetWritePermissionGrant(
-            kTestStorageKey.origin(), path_info,
-            FileSystemAccessPermissionContext::HandleType::kFile, user_action))
-        .WillOnce(testing::Return(allow_grant_));
+          FileSystemAccessPermissionContext::UserAction::kOpen,
+      FileSystemAccessPermissionContext::HandleType handle_type =
+          FileSystemAccessPermissionContext::HandleType::kFile,
+      scoped_refptr<FixedFileSystemAccessPermissionGrant> grant = nullptr) {
+    EXPECT_CALL(permission_context_,
+                GetWritePermissionGrant(kTestStorageKey.origin(), path_info,
+                                        handle_type, user_action))
+        .WillOnce(testing::Return(grant ? grant : allow_grant_));
+  }
+
+  void ExpectShowFilePicker(
+      bool read_permission = true,
+      bool write_permission = false,
+      std::optional<PathInfo> last_picked_directory_to_set = std::nullopt) {
+    if (read_permission) {
+      EXPECT_CALL(permission_context_,
+                  CanObtainReadPermission(kTestStorageKey.origin()))
+          .WillOnce(testing::Return(true));
+    }
+    if (write_permission) {
+      EXPECT_CALL(permission_context_,
+                  CanObtainWritePermission(kTestStorageKey.origin()))
+          .WillOnce(testing::Return(true));
+    }
+    EXPECT_CALL(permission_context_,
+                GetWellKnownDirectoryPath(
+                    blink::mojom::WellKnownDirectory::kDirDocuments,
+                    kTestStorageKey.origin()))
+        .WillOnce(testing::Return(base::FilePath()));
+    EXPECT_CALL(permission_context_,
+                GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
+        .WillOnce(testing::Return(PathInfo()));
+    EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
+        .WillOnce(testing::Return(std::u16string()));
+    if (last_picked_directory_to_set) {
+      EXPECT_CALL(
+          permission_context_,
+          SetLastPickedDirectory(kTestStorageKey.origin(), std::string(),
+                                 *last_picked_directory_to_set));
+    }
+  }
+
+  void ExpectCheckPathsAgainstEnterprisePolicy(bool allowed = true) {
+    EXPECT_CALL(permission_context_, CheckPathsAgainstEnterprisePolicy(
+                                         testing::_, testing::_, testing::_))
+        .WillOnce(
+            [allowed](std::vector<PathInfo> entries,
+                      content::GlobalRenderFrameHostId frame_id,
+                      MockFileSystemAccessPermissionContext::
+                          EntriesAllowedByEnterprisePolicyCallback callback) {
+              std::move(callback).Run(allowed ? std::move(entries)
+                                              : std::vector<PathInfo>());
+            });
   }
 
   FileSystemAccessTransferTokenImpl* SerializeAndDeserializeToken(
@@ -1640,37 +1722,16 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
-  EXPECT_CALL(permission_context_,
-              SetLastPickedDirectory(kTestStorageKey.origin(), std::string(),
-                                     PathInfo(test_file_info.path.DirName())));
+  ExpectShowFilePicker(
+      /*read_permission=*/true, /*write_permission=*/false,
+      PathInfo(test_file_info.path.DirName()));
 
   ExpectConfirmSensitiveEntryAccess(
       test_file_info,
       FileSystemAccessPermissionContext::SensitiveEntryResult::kAllowed);
   ExpectGetReadPermissionGrant(test_file_info);
   ExpectGetWritePermissionGrant(test_file_info);
-  EXPECT_CALL(permission_context_, CheckPathsAgainstEnterprisePolicy(
-                                       testing::_, testing::_, testing::_))
-      .WillOnce([](std::vector<PathInfo> entries,
-                   content::GlobalRenderFrameHostId frame_id,
-                   MockFileSystemAccessPermissionContext::
-                       EntriesAllowedByEnterprisePolicyCallback callback) {
-        std::move(callback).Run(std::move(entries));
-      });
+  ExpectCheckPathsAgainstEnterprisePolicy();
 
   auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
       blink::mojom::AcceptsTypesInfo::New(
@@ -1752,23 +1813,9 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
-  EXPECT_CALL(permission_context_,
-              SetLastPickedDirectory(kTestStorageKey.origin(), std::string(),
-                                     PathInfo(test_file_info1.path.DirName())));
+  ExpectShowFilePicker(
+      /*read_permission=*/true, /*write_permission=*/false,
+      PathInfo(test_file_info1.path.DirName()));
 
   // ConfirmSensitiveEntryAccess should be called for BOTH files.
   testing::Expectation e1 = ExpectConfirmSensitiveEntryAccess(
@@ -1785,14 +1832,7 @@
   ExpectGetReadPermissionGrant(test_file_info2);
   ExpectGetWritePermissionGrant(test_file_info2);
 
-  EXPECT_CALL(permission_context_, CheckPathsAgainstEnterprisePolicy(
-                                       testing::_, testing::_, testing::_))
-      .WillOnce([](std::vector<PathInfo> entries,
-                   content::GlobalRenderFrameHostId frame_id,
-                   MockFileSystemAccessPermissionContext::
-                       EntriesAllowedByEnterprisePolicyCallback callback) {
-        std::move(callback).Run(std::move(entries));
-      });
+  ExpectCheckPathsAgainstEnterprisePolicy();
 
   auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
       blink::mojom::AcceptsTypesInfo::New(
@@ -1840,20 +1880,7 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
+  ExpectShowFilePicker();
 
   // ConfirmSensitiveEntryAccess is called for BOTH. The first is allowed, the
   // second is aborted.
@@ -1912,20 +1939,7 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
+  ExpectShowFilePicker();
 
   // ConfirmSensitiveEntryAccess is called for the first file and is aborted.
   // The second file should NOT be checked.
@@ -1985,20 +1999,7 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
+  ExpectShowFilePicker();
 
   // Mock CanShowFilePicker to synchronously destroy the WebContents, which
   // destroys the RFH. This simulates the frame being detached/destroyed
@@ -2035,6 +2036,149 @@
             future.Get<0>()->status);
 }
 
+// Chooser should not be shown when WebContents is hidden.
+TEST_F(FileSystemAccessManagerImplTest, ChooseEntries_HiddenWebContents) {
+  static_cast<TestRenderFrameHost*>(web_contents_->GetPrimaryMainFrame())
+      ->SimulateUserActivation();
+
+  // Must set VISIBLE first before HIDDEN will work.
+  web_contents_->UpdateWebContentsVisibility(Visibility::VISIBLE);
+  web_contents_->UpdateWebContentsVisibility(Visibility::HIDDEN);
+
+  mojo::Remote<blink::mojom::FileSystemAccessManager> manager_remote;
+  FileSystemAccessManagerImpl::BindingContext binding_context = {
+      kTestStorageKey, kTestURL,
+      web_contents_->GetPrimaryMainFrame()->GetGlobalId()};
+  manager_->BindReceiver(binding_context,
+                         manager_remote.BindNewPipeAndPassReceiver());
+
+  ExpectShowFilePicker();
+
+  auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
+      blink::mojom::AcceptsTypesInfo::New(
+          std::vector<blink::mojom::ChooseFileSystemEntryAcceptsOptionPtr>(),
+          /*include_accepts_all=*/true),
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
index 78c13353..bbee1ae 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
@@ -37,6 +37,9 @@
 #include "content/browser/file_system_access/fixed_file_system_access_permission_grant.h"
 #include "content/browser/file_system_access/mock_file_system_access_permission_context.h"
 #include "content/public/browser/content_browser_client.h"
+#include "content/public/browser/file_select_listener.h"
+#include "content/public/browser/visibility.h"
+#include "content/public/browser/web_contents_delegate.h"
 #include "content/public/test/browser_task_environment.h"
 #include "content/public/test/test_browser_context.h"
 #include "content/public/test/test_utils.h"
@@ -69,6 +72,10 @@
 #include "ui/shell_dialogs/select_file_dialog.h"
 #include "url/gurl.h"
 
+#if BUILDFLAG(IS_ANDROID)
+#include "third_party/blink/public/mojom/choosers/file_chooser.mojom.h"
+#endif
+
 namespace content {
 
 namespace {
@@ -155,6 +162,34 @@
   bool was_checked_ = false;
 };
 
+#if BUILDFLAG(IS_ANDROID)
+// A WebContentsDelegate that simulates WebView by overriding
+// `UseFileChooserForFileSystemAccess()` and handling `RunFileChooser()`.
+class TestWebViewWebContentsDelegate : public WebContentsDelegate {
+ public:
+  bool UseFileChooserForFileSystemAccess() const override { return true; }
+
+  void RunFileChooser(RenderFrameHost* render_frame_host,
+                      scoped_refptr<FileSelectListener> listener,
+                      const blink::mojom::FileChooserParams& params) override {
+    run_file_chooser_called_ = true;
+    listener->FileSelected(std::move(files_), base::FilePath(), params.mode);
+  }
+
+  void SetFileToSelect(base::FilePath file) {
+    files_.push_back(blink::mojom::FileChooserFileInfo::NewNativeFile(
+        blink::mojom::NativeFileInfo::New(file, std::u16string(),
+                                          std::vector<std::u16string>())));
+  }
+
+  bool run_file_chooser_called() const { return run_file_chooser_called_; }
+
+ private:
+  bool run_file_chooser_called_ = false;
+  std::vector<blink::mojom::FileChooserFileInfoPtr> files_;
+};
+#endif
+
 }  // namespace
 
 using base::test::RunOnceCallback;
@@ -261,11 +296,12 @@
       FileSystemAccessPermissionContext::SensitiveEntryResult result,
       FileSystemAccessPermissionContext::UserAction user_action =
           FileSystemAccessPermissionContext::UserAction::kOpen,
-      testing::ExpectationSet after_expectations = {}) {
+      testing::ExpectationSet after_expectations = {},
+      FileSystemAccessPermissionContext::HandleType handle_type =
+          FileSystemAccessPermissionContext::HandleType::kFile) {
     return EXPECT_CALL(permission_context_,
                        ConfirmSensitiveEntryAccess_(
-                           kTestStorageKey.origin(), path_info,
-                           FileSystemAccessPermissionContext::HandleType::kFile,
+                           kTestStorageKey.origin(), path_info, handle_type,
                            user_action,
                            web_contents_->GetPrimaryMainFrame()->GetGlobalId(),
                            testing::_))
@@ -276,25 +312,71 @@
   void ExpectGetReadPermissionGrant(
       const PathInfo& path_info,
       FileSystemAccessPermissionContext::UserAction user_action =
-          FileSystemAccessPermissionContext::UserAction::kOpen) {
-    EXPECT_CALL(
-        permission_context_,
-        GetReadPermissionGrant(
-            kTestStorageKey.origin(), path_info,
-            FileSystemAccessPermissionContext::HandleType::kFile, user_action))
+          FileSystemAccessPermissionContext::UserAction::kOpen,
+      FileSystemAccessPermissionContext::HandleType handle_type =
+          FileSystemAccessPermissionContext::HandleType::kFile) {
+    EXPECT_CALL(permission_context_,
+                GetReadPermissionGrant(kTestStorageKey.origin(), path_info,
+                                       handle_type, user_action))
         .WillOnce(testing::Return(allow_grant_));
   }
 
   void ExpectGetWritePermissionGrant(
       const PathInfo& path_info,
       FileSystemAccessPermissionContext::UserAction user_action =
-          FileSystemAccessPermissionContext::UserAction::kOpen) {
-    EXPECT_CALL(
-        permission_context_,
-        GetWritePermissionGrant(
-            kTestStorageKey.origin(), path_info,
-            FileSystemAccessPermissionContext::HandleType::kFile, user_action))
-        .WillOnce(testing::Return(allow_grant_));
+          FileSystemAccessPermissionContext::UserAction::kOpen,
+      FileSystemAccessPermissionContext::HandleType handle_type =
+          FileSystemAccessPermissionContext::HandleType::kFile,
+      scoped_refptr<FixedFileSystemAccessPermissionGrant> grant = nullptr) {
+    EXPECT_CALL(permission_context_,
+                GetWritePermissionGrant(kTestStorageKey.origin(), path_info,
+                                        handle_type, user_action))
+        .WillOnce(testing::Return(grant ? grant : allow_grant_));
+  }
+
+  void ExpectShowFilePicker(
+      bool read_permission = true,
+      bool write_permission = false,
+      std::optional<PathInfo> last_picked_directory_to_set = std::nullopt) {
+    if (read_permission) {
+      EXPECT_CALL(permission_context_,
+                  CanObtainReadPermission(kTestStorageKey.origin()))
+          .WillOnce(testing::Return(true));
+    }
+    if (write_permission) {
+      EXPECT_CALL(permission_context_,
+                  CanObtainWritePermission(kTestStorageKey.origin()))
+          .WillOnce(testing::Return(true));
+    }
+    EXPECT_CALL(permission_context_,
+                GetWellKnownDirectoryPath(
+                    blink::mojom::WellKnownDirectory::kDirDocuments,
+                    kTestStorageKey.origin()))
+        .WillOnce(testing::Return(base::FilePath()));
+    EXPECT_CALL(permission_context_,
+                GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
+        .WillOnce(testing::Return(PathInfo()));
+    EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
+        .WillOnce(testing::Return(std::u16string()));
+    if (last_picked_directory_to_set) {
+      EXPECT_CALL(
+          permission_context_,
+          SetLastPickedDirectory(kTestStorageKey.origin(), std::string(),
+                                 *last_picked_directory_to_set));
+    }
+  }
+
+  void ExpectCheckPathsAgainstEnterprisePolicy(bool allowed = true) {
+    EXPECT_CALL(permission_context_, CheckPathsAgainstEnterprisePolicy(
+                                         testing::_, testing::_, testing::_))
+        .WillOnce(
+            [allowed](std::vector<PathInfo> entries,
+                      content::GlobalRenderFrameHostId frame_id,
+                      MockFileSystemAccessPermissionContext::
+                          EntriesAllowedByEnterprisePolicyCallback callback) {
+              std::move(callback).Run(allowed ? std::move(entries)
+                                              : std::vector<PathInfo>());
+            });
   }
 
   FileSystemAccessTransferTokenImpl* SerializeAndDeserializeToken(
@@ -1640,37 +1722,16 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
-  EXPECT_CALL(permission_context_,
-              SetLastPickedDirectory(kTestStorageKey.origin(), std::string(),
-                                     PathInfo(test_file_info.path.DirName())));
+  ExpectShowFilePicker(
+      /*read_permission=*/true, /*write_permission=*/false,
+      PathInfo(test_file_info.path.DirName()));
 
   ExpectConfirmSensitiveEntryAccess(
       test_file_info,
       FileSystemAccessPermissionContext::SensitiveEntryResult::kAllowed);
   ExpectGetReadPermissionGrant(test_file_info);
   ExpectGetWritePermissionGrant(test_file_info);
-  EXPECT_CALL(permission_context_, CheckPathsAgainstEnterprisePolicy(
-                                       testing::_, testing::_, testing::_))
-      .WillOnce([](std::vector<PathInfo> entries,
-                   content::GlobalRenderFrameHostId frame_id,
-                   MockFileSystemAccessPermissionContext::
-                       EntriesAllowedByEnterprisePolicyCallback callback) {
-        std::move(callback).Run(std::move(entries));
-      });
+  ExpectCheckPathsAgainstEnterprisePolicy();
 
   auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
       blink::mojom::AcceptsTypesInfo::New(
@@ -1752,23 +1813,9 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
-  EXPECT_CALL(permission_context_,
-              SetLastPickedDirectory(kTestStorageKey.origin(), std::string(),
-                                     PathInfo(test_file_info1.path.DirName())));
+  ExpectShowFilePicker(
+      /*read_permission=*/true, /*write_permission=*/false,
+      PathInfo(test_file_info1.path.DirName()));
 
   // ConfirmSensitiveEntryAccess should be called for BOTH files.
   testing::Expectation e1 = ExpectConfirmSensitiveEntryAccess(
@@ -1785,14 +1832,7 @@
   ExpectGetReadPermissionGrant(test_file_info2);
   ExpectGetWritePermissionGrant(test_file_info2);
 
-  EXPECT_CALL(permission_context_, CheckPathsAgainstEnterprisePolicy(
-                                       testing::_, testing::_, testing::_))
-      .WillOnce([](std::vector<PathInfo> entries,
-                   content::GlobalRenderFrameHostId frame_id,
-                   MockFileSystemAccessPermissionContext::
-                       EntriesAllowedByEnterprisePolicyCallback callback) {
-        std::move(callback).Run(std::move(entries));
-      });
+  ExpectCheckPathsAgainstEnterprisePolicy();
 
   auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
       blink::mojom::AcceptsTypesInfo::New(
@@ -1840,20 +1880,7 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
+  ExpectShowFilePicker();
 
   // ConfirmSensitiveEntryAccess is called for BOTH. The first is allowed, the
   // second is aborted.
@@ -1912,20 +1939,7 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
-      GetWellKnownDirectoryPath(blink::mojom::WellKnownDirectory::kDirDocuments,
-                                kTestStorageKey.origin()))
-      .WillOnce(testing::Return(base::FilePath()));
-  EXPECT_CALL(permission_context_,
-              GetLastPickedDirectory(kTestStorageKey.origin(), std::string()))
-      .WillOnce(testing::Return(PathInfo()));
-  EXPECT_CALL(permission_context_, GetPickerTitle(testing::_))
-      .WillOnce(testing::Return(std::u16string()));
+  ExpectShowFilePicker();
 
   // ConfirmSensitiveEntryAccess is called for the first file and is aborted.
   // The second file should NOT be checked.
@@ -1985,20 +1999,7 @@
   manager_->BindReceiver(binding_context,
                          manager_remote.BindNewPipeAndPassReceiver());
 
-  EXPECT_CALL(permission_context_,
-              CanObtainReadPermission(kTestStorageKey.origin()))
-      .WillOnce(testing::Return(true));
-
-  EXPECT_CALL(
-      permission_context_,
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Potential UI spoofing via hidden Android WebView File System Access picker

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.

Overview: A potential vulnerability in Android WebView allows a compromised renderer in a hidden background context to trigger a system file picker. By bypassing visibility checks and user activation enforcement, an attacker can spoof a file picker request over the foreground UI to gain unauthorized local file access.

Affected files:

  • content/browser/file_system_access/file_system_access_manager_impl.cc
  • android_webview/browser/aw_web_contents_delegate.cc
  • android_webview/browser/file_system_access/aw_file_system_access_permission_context.cc
  • android_webview/java/src/org/chromium/android_webview/AwWebContentsDelegateAdapter.java
  • content/browser/file_system_access/file_system_chooser.cc

Estimated timestamp from git blame: 2025-11-21

Background

In Chrome, security measures prevent hidden WebContents from opening File System Access (FSA) pickers to protect against UI spoofing. This is typically enforced via a visibility gate (WebContentsBasedCanceller with CancelCondition::kVisibility) inside FileSystemChooser::CreateAndShow.

Potential Vulnerability

Android WebView potentially bypasses this visibility gate due to an early return path in the browser process.

  1. User Activation Bypass: When a compromised renderer sends a blink.mojom.FileSystemAccessManager::ChooseEntries IPC to the browser process, FileSystemAccessManagerImpl::ChooseEntries attempts to consume transient user activation. However, it ignores the boolean return value of FrameTreeNode::UpdateUserActivationState. It consumes the activation if present, but does not reject the request if the activation was missing, allowing the flow to proceed without a valid user gesture.
  2. Visibility Check Bypass: The request eventually reaches FileSystemAccessManagerImpl::ShowFilePickerOnUIThread. On Android, if delegate->UseFileChooserForFileSystemAccess() is true (which it unconditionally is for AwWebContentsDelegate), the code routes to delegate->RunFileChooser(...) and returns early.
  3. Missing Enforcement: This early return completely skips the execution of FileSystemChooser::CreateAndShow, which is responsible for creating the WebContentsBasedCanceller that enforces the visibility requirement. Neither AwWebContentsDelegate::RunFileChooser (C++) nor AwWebContentsDelegateAdapter.runFileChooser (Java) implements an equivalent visibility check before invoking the embedder’s onShowFileChooser.

Suggested Attacker Steps

Note: Our tooling agent does not have the ability to run code, so these are potential steps an attacker might follow:

  1. An attacker embeds a hidden WebView in an Android app (e.g., using View.GONE or in a background tab) and compromises its renderer process via a separate vulnerability.
  2. The compromised renderer constructs and sends a ChooseEntries IPC directly to the browser process, bypassing renderer-side user gesture checks.
  3. The browser process processes the request, bypassing the user activation requirement and the visibility gate, and triggers the Android system file picker via the embedder application.
  4. The user sees a system file picker appearing over the current active foreground UI (which may be a benign part of the app or another app entirely).
  5. Believing the request is legitimate, the user selects a sensitive file.
  6. The hidden attacker-controlled renderer receives a FileSystemFileHandle with access to the chosen file.

Suggested Fix

The browser process should enforce visibility and user activation requirements consistently.

  1. In FileSystemAccessManagerImpl::ShowFilePickerOnUIThread, if delegating to the Android WebContentsDelegate, a WebContentsBasedCanceller (or equivalent mechanism) must still be instantiated to abort the picker if the WebContents becomes hidden.
  2. FileSystemAccessManagerImpl::ChooseEntries should strictly verify that transient user activation was successfully consumed (or was explicitly not required) before proceeding.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker