CVE-2026-84323
Overview
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, orHIDDENto the user. - `kOperationAborted`
- the
FileSystemAccessStatusvalue 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.
VISIBLE (non-hidden) WebContents.Attack Path
- 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
RenderFrameHostis stillIsActive(). - Trigger the picker while hidden
Script calls
showOpenFilePickerorshowDirectoryPicker, reachingShowFilePickerOnUIThreadeven thoughWebContents::GetVisibility()isHIDDEN. - Bypass the stale guard
The pre-fix guard passes because
web_contentsis non-null andoutermost_rfh->IsActive()is true, so the native chooser is created despite the tab being hidden. - 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
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
| Function | Change | Notes |
|---|---|---|
TestWebViewWebContentsDelegatecontent/browser/file_system_access/file_system_access_manager_impl_unittest.cc |
modified | |
ifcontent/browser/file_system_access/file_system_access_manager_impl_unittest.cc |
modified | |
TEST_Fcontent/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.cccontent/browser/file_system_access/features.hcontent/browser/file_system_access/features.cccontent/browser/file_system_access/file_system_access_manager_impl.cc
Audit Directions
- Visibility vs. activity conflationAudit other consent- or dialog-raising browser paths that gate on
RenderFrameHost::IsActive()alone and add aWebContents::GetVisibility()check where a prompt must come from the foreground page. - Chooser and permission entry pointsReview every caller that reaches a native chooser, permission prompt, or
SelectFileDialogfrom a renderer-initiated request to confirm the requesting surface is currently visible. - Feature-flag-gated security fixesTrack
kFileSystemAccessCheckHiddenand similar flags to ensure the protective check cannot be disabled to reopen the bypass, and verify parity across platforms including Android/WebView delegates.
Patch
From 386998126bfe0cbabf2ea81be1fbd4cd1a34f2c8 Mon Sep 17 00:00:00 2001
From: Joel Hockey <joelhockey@chromium.org>
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 <joelhockey@chromium.org>
Reviewed-by: Fergal Daly <fergal@chromium.org>
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)
Regression Test / PoC
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)
Original Bug Report
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.ccandroid_webview/browser/aw_web_contents_delegate.ccandroid_webview/browser/file_system_access/aw_file_system_access_permission_context.ccandroid_webview/java/src/org/chromium/android_webview/AwWebContentsDelegateAdapter.javacontent/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.
- User Activation Bypass: When a compromised renderer sends a
blink.mojom.FileSystemAccessManager::ChooseEntriesIPC to the browser process,FileSystemAccessManagerImpl::ChooseEntriesattempts to consume transient user activation. However, it ignores the boolean return value ofFrameTreeNode::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. - Visibility Check Bypass: The request eventually reaches
FileSystemAccessManagerImpl::ShowFilePickerOnUIThread. On Android, ifdelegate->UseFileChooserForFileSystemAccess()is true (which it unconditionally is forAwWebContentsDelegate), the code routes todelegate->RunFileChooser(...)and returns early. - Missing Enforcement: This early return completely skips the execution of
FileSystemChooser::CreateAndShow, which is responsible for creating theWebContentsBasedCancellerthat enforces the visibility requirement. NeitherAwWebContentsDelegate::RunFileChooser(C++) norAwWebContentsDelegateAdapter.runFileChooser(Java) implements an equivalent visibility check before invoking the embedder’sonShowFileChooser.
Suggested Attacker Steps
Note: Our tooling agent does not have the ability to run code, so these are potential steps an attacker might follow:
- An attacker embeds a hidden WebView in an Android app (e.g., using
View.GONEor in a background tab) and compromises its renderer process via a separate vulnerability. - The compromised renderer constructs and sends a
ChooseEntriesIPC directly to the browser process, bypassing renderer-side user gesture checks. - 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.
- 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).
- Believing the request is legitimate, the user selects a sensitive file.
- The hidden attacker-controlled renderer receives a
FileSystemFileHandlewith access to the chosen file.
Suggested Fix
The browser process should enforce visibility and user activation requirements consistently.
- In
FileSystemAccessManagerImpl::ShowFilePickerOnUIThread, if delegating to the AndroidWebContentsDelegate, aWebContentsBasedCanceller(or equivalent mechanism) must still be instantiated to abort the picker if theWebContentsbecomes hidden. FileSystemAccessManagerImpl::ChooseEntriesshould 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.