CVE-2026-106198
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/file_system_access/file_system_access_manager_impl.cc |
modified |
Files Changed
content/browser/file_system_access/file_system_access_observer_browsertest.cccontent/browser/file_system_access/file_system_access_manager_impl_unittest.cccontent/browser/file_system_access/file_system_access_manager_impl.cc
Patch
From 76f1225c09c11d6ca9c958c05577772e461f28bb Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <elkurin@google.com>
Date: Fri, 11 Sep 2026 02:57:04 -0700
Subject: [PATCH] Validate origin of start_in directory token in ChooseEntries
In FileSystemAccessManagerImpl::ResolveDefaultDirectory, verify that the
origin of a resolved start_in directory token matches the origin of the
calling binding context. If the origins do not match, reject the request
with kInvalidArgument.
This brings ResolveDefaultDirectory into alignment with sibling transfer
token resolution methods, such as DidResolveTransferTokenForFileHandle,
DidResolveTransferTokenForDirectoryHandle, DidResolveTokenToMove, and
DidResolveTokenToObserve, that already enforce origin matching for
resolved tokens.
TAG=agy
Bug: 510773353
Change-Id: Ic748fe6bd37225d35667648683195f7954459a63
Fixed: 510773353
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8366025
Commit-Queue: Eriko Kurimoto <elkurin@chromium.org>
Reviewed-by: Ming-Ying Chung <mych@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1696077}
---
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 1675667..d045e2b 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
@@ -781,6 +781,15 @@
FileSystemAccessTransferTokenImpl* resolved_directory_token) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+ if (resolved_directory_token &&
+ resolved_directory_token->origin() != context.storage_key.origin()) {
+ std::move(callback).Run(
+ file_system_access_error::FromStatus(
+ FileSystemAccessStatus::kInvalidArgument),
+ std::vector<blink::mojom::FileSystemAccessEntryPtr>());
+ return;
+ }
+
PathInfo path_info;
if (resolved_directory_token) {
// Prioritize an explicitly stated directory handle to start in over an `id`
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 bbee1ae..d4041fe6 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
@@ -2225,6 +2225,77 @@
EXPECT_TRUE(rfh->HasTransientUserActivation());
}
+// A `start_in` directory token that belongs to a different origin than the
+// calling context must be rejected instead of being used as the picker's
+// default directory.
+TEST_F(FileSystemAccessManagerImplTest,
+ ChooseEntries_CrossOriginStartInTokenRejected) {
+ base::FilePath other_origin_dir = dir_.GetPath().AppendASCII("other");
+ ASSERT_TRUE(base::CreateDirectory(other_origin_dir));
+ base::FilePath file_in_dir = other_origin_dir.AppendASCII("file");
+ ASSERT_TRUE(base::WriteFile(file_in_dir, "data"));
+ PathInfo other_origin_dir_info(other_origin_dir);
+
+ // Create a directory handle owned by another origin and register a
+ // transfer token for it.
+ const GURL kOtherURL("https://other.example/test");
+ const blink::StorageKey kOtherStorageKey =
+ blink::StorageKey::CreateFromStringForTesting(kOtherURL.spec());
+ ASSERT_NE(kTestStorageKey.origin(), kOtherStorageKey.origin());
+ FileSystemAccessManagerImpl::BindingContext other_binding_context = {
+ kOtherStorageKey, kOtherURL,
+ web_contents_->GetPrimaryMainFrame()->GetGlobalId()};
+ auto grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+ FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+ other_origin_dir_info);
+ FileSystemAccessDirectoryHandleImpl directory_handle(
+ manager_.get(), other_binding_context,
+ manager_->CreateFileSystemURLFromPath(other_origin_dir_info),
+ FileSystemAccessManagerImpl::SharedHandleState(grant, grant));
+ mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+ manager_->CreateTransferToken(directory_handle,
+ token_remote.InitWithNewPipeAndPassReceiver());
+
+ manager_->SetFilePickerResultForTesting(PathInfo(file_in_dir));
+
+ static_cast<TestRenderFrameHost*>(web_contents_->GetPrimaryMainFrame())
+ ->SimulateUserActivation();
+
+ mojo::Remote<blink::mojom::FileSystemAccessManager> manager_remote;
+ FileSystemAccessManagerImpl::BindingContext binding_context = {
+ kTestStorageKey, kTestURL,
+ web_contents_->GetPrimaryMainFrame()->GetGlobalId()};
+ manager_->BindReceiver(binding_context,
+ manager_remote.BindNewPipeAndPassReceiver());
+
+ EXPECT_CALL(permission_context_,
+ CanObtainReadPermission(kTestStorageKey.origin()))
+ .WillOnce(testing::Return(true));
+
+ auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
+ blink::mojom::AcceptsTypesInfo::New(
+ std::vector<blink::mojom::ChooseFileSystemEntryAcceptsOptionPtr>(),
+ /*include_accepts_all=*/true),
+ /*can_select_multiple_files=*/false);
+ auto picker_options = blink::mojom::FilePickerOptions::New(
+ blink::mojom::TypeSpecificFilePickerOptionsUnion::
+ NewOpenFilePickerOptions(std::move(open_file_picker_options)),
+ /*starting_directory_id=*/std::string(),
+ blink::mojom::FilePickerStartInOptionsUnion::NewDirectoryToken(
+ std::move(token_remote)));
+
+ base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr,
+ std::vector<blink::mojom::FileSystemAccessEntryPtr>>
+ future;
+ manager_remote->ChooseEntries(std::move(picker_options),
+ future.GetCallback());
+ ASSERT_TRUE(future.Wait());
+
+ EXPECT_EQ(blink::mojom::FileSystemAccessStatus::kInvalidArgument,
+ future.Get<0>()->status);
+ EXPECT_TRUE(future.Get<1>().empty());
+}
+
TEST_F(FileSystemAccessManagerImplTest,
ChooseEntries_OpenFile_EnterpriseBlock) {
base::FilePath test_file = dir_.GetPath().AppendASCII("foo");
diff --git a/content/browser/file_system_access/file_system_access_observer_browsertest.cc b/content/browser/file_system_access/file_system_access_observer_browsertest.cc
index b0c36f1..cdb0cae 100644
--- a/content/browser/file_system_access/file_system_access_observer_browsertest.cc
+++ b/content/browser/file_system_access/file_system_access_observer_browsertest.cc
@@ -1691,6 +1691,123 @@
blink::mojom::FileSystemAccessStatus::kInvalidArgument);
}
+// Checks that a `start_in` directory TransferToken from an unexpected origin
+// cannot be used to pick the directory the file picker opens in. Same pattern
+// as the move variant above, applied to the ChooseEntries() code path in
+// ResolveDefaultDirectory.
+IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest,
+ ChooseEntriesRefusesCrossOriginStartInToken) {
+ base::FilePath victim_dir_path;
+ base::FilePath file_in_victim_dir_path;
+ {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ ASSERT_TRUE(base::CreateTemporaryDirInDir(
+ temp_dir_.GetPath(), FILE_PATH_LITERAL("victim"), &victim_dir_path));
+ ASSERT_TRUE(base::CreateTemporaryFileInDir(victim_dir_path,
+ &file_in_victim_dir_path));
+ }
+
+ // Victim site.
+ GURL url_victim = GetURL("a.com", "/title1.html");
+ const url::Origin origin_victim = url::Origin::Create(url_victim);
+ const blink::StorageKey key_victim =
+ blink::StorageKey::CreateFirstParty(origin_victim);
+ // Attacker site. Uses the loopback host so that its origin is potentially
+ // trustworthy, which BindReceiver() requires.
+ GURL url_attacker = GetURL("/title1.html");
+ const url::Origin origin_attacker = url::Origin::Create(url_attacker);
+ const blink::StorageKey key_attacker =
+ blink::StorageKey::CreateFirstParty(origin_attacker);
+ ASSERT_NE(origin_victim, origin_attacker);
+
+ // If the picker were shown, it would select a file inside origin A's
+ // directory. `dialog_params` records whether a dialog was created at all.
+ SelectFileDialogParams dialog_params;
+ ui::SelectFileDialog::SetFactory(
+ std::make_unique<FakeSelectFileDialogFactory>(
+ std::vector<base::FilePath>{file_in_victim_dir_path},
+ &dialog_params));
+
+ // Navigate to the attacker site, whose frame issues the ChooseEntries()
+ // call.
+ ASSERT_TRUE(NavigateToURL(shell(), url_attacker));
+ RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+ ASSERT_TRUE(rfh);
+
+ auto* manager = GetManager();
+ ASSERT_TRUE(manager);
+
+ // Origin A owns the directory with read+write grants.
+ const storage::FileSystemURL victim_dir_url =
+ manager->CreateFileSystemURLFromPath(PathInfo(victim_dir_path));
+ auto victim_read_grant =
+ base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+ FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+ PathInfo(victim_dir_path));
+ auto victim_write_grant =
+ base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+ FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+ PathInfo(victim_dir_path));
+ FileSystemAccessManagerImpl::SharedHandleState victim_handle_state(
+ victim_read_grant, victim_write_grant);
+ FileSystemAccessManagerImpl::BindingContext victim_context(
+ key_victim, url_victim, rfh->GetGlobalId());
+ auto victim_dir_handle =
+ std::make_unique<FileSystemAccessDirectoryHandleImpl>(
+ manager, victim_context, victim_dir_url, victim_handle_state);
+
+ // Transfer token for origin A's directory, registered in the manager.
+ mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+ manager->CreateTransferToken(*victim_dir_handle,
+ token_remote.InitWithNewPipeAndPassReceiver());
+
+ // Bind a FileSystemAccessManager for origin B.
+ FileSystemAccessManagerImpl::BindingContext attacker_context(
+ key_attacker, url_attacker, rfh->GetGlobalId());
+ mojo::Remote<blink::mojom::FileSystemAccessManager> attacker_manager;
+ manager->BindReceiver(attacker_context,
+ attacker_manager.BindNewPipeAndPassReceiver());
+
+ // ChooseEntries() consumes transient user activation; ExecJs grants it.
+ EXPECT_TRUE(ExecJs(shell(), "true"));
+
+ // Call ChooseEntries() from origin B's manager with origin A's directory
+ // TransferToken as the `start_in` option.
+ auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
+ blink::mojom::AcceptsTypesInfo::New(
+ std::vector<blink::mojom::ChooseFileSystemEntryAcceptsOptionPtr>(),
+ /*include_accepts_all=*/true),
+ /*can_select_multiple_files=*/false);
+ auto picker_options = blink::mojom::FilePickerOptions::New(
+ blink::mojom::TypeSpecificFilePickerOptionsUnion::
+ NewOpenFilePickerOptions(std::move(open_file_picker_options)),
+ /*starting_directory_id=*/std::string(),
+ blink::mojom::FilePickerStartInOptionsUnion::NewDirectoryToken(
+ std::move(token_remote)));
+
+ base::RunLoop run_loop;
+ blink::mojom::FileSystemAccessStatus choose_entries_status;
+ size_t entry_count = 0;
+ attacker_manager->ChooseEntries(
+ std::move(picker_options),
+ base::BindLambdaForTesting(
+ [&](blink::mojom::FileSystemAccessErrorPtr result,
+ std::vector<blink::mojom::FileSystemAccessEntryPtr> entries) {
+ choose_entries_status = result->status;
+ entry_count = entries.size();
+ run_loop.Quit();
+ }));
+ run_loop.Run();
+
+ // ResolveDefaultDirectory must compare the resolved token's origin against
+ // the caller's binding-context origin and reject the mismatch with
+ // kInvalidArgument, without showing a file picker.
+ ASSERT_EQ(choose_entries_status,
+ blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+ EXPECT_EQ(entry_count, 0u);
+ EXPECT_EQ(dialog_params.type, ui::SelectFileDialog::SELECT_NONE);
+}
+
// Checks that a possible-child transfer token from a different origin cannot
// be resolved against a directory handle from the calling origin.
IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest,
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 bbee1ae..d4041fe6 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
@@ -2225,6 +2225,77 @@
EXPECT_TRUE(rfh->HasTransientUserActivation());
}
+// A `start_in` directory token that belongs to a different origin than the
+// calling context must be rejected instead of being used as the picker's
+// default directory.
+TEST_F(FileSystemAccessManagerImplTest,
+ ChooseEntries_CrossOriginStartInTokenRejected) {
+ base::FilePath other_origin_dir = dir_.GetPath().AppendASCII("other");
+ ASSERT_TRUE(base::CreateDirectory(other_origin_dir));
+ base::FilePath file_in_dir = other_origin_dir.AppendASCII("file");
+ ASSERT_TRUE(base::WriteFile(file_in_dir, "data"));
+ PathInfo other_origin_dir_info(other_origin_dir);
+
+ // Create a directory handle owned by another origin and register a
+ // transfer token for it.
+ const GURL kOtherURL("https://other.example/test");
+ const blink::StorageKey kOtherStorageKey =
+ blink::StorageKey::CreateFromStringForTesting(kOtherURL.spec());
+ ASSERT_NE(kTestStorageKey.origin(), kOtherStorageKey.origin());
+ FileSystemAccessManagerImpl::BindingContext other_binding_context = {
+ kOtherStorageKey, kOtherURL,
+ web_contents_->GetPrimaryMainFrame()->GetGlobalId()};
+ auto grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+ FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+ other_origin_dir_info);
+ FileSystemAccessDirectoryHandleImpl directory_handle(
+ manager_.get(), other_binding_context,
+ manager_->CreateFileSystemURLFromPath(other_origin_dir_info),
+ FileSystemAccessManagerImpl::SharedHandleState(grant, grant));
+ mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+ manager_->CreateTransferToken(directory_handle,
+ token_remote.InitWithNewPipeAndPassReceiver());
+
+ manager_->SetFilePickerResultForTesting(PathInfo(file_in_dir));
+
+ static_cast<TestRenderFrameHost*>(web_contents_->GetPrimaryMainFrame())
+ ->SimulateUserActivation();
+
+ mojo::Remote<blink::mojom::FileSystemAccessManager> manager_remote;
+ FileSystemAccessManagerImpl::BindingContext binding_context = {
+ kTestStorageKey, kTestURL,
+ web_contents_->GetPrimaryMainFrame()->GetGlobalId()};
+ manager_->BindReceiver(binding_context,
+ manager_remote.BindNewPipeAndPassReceiver());
+
+ EXPECT_CALL(permission_context_,
+ CanObtainReadPermission(kTestStorageKey.origin()))
+ .WillOnce(testing::Return(true));
+
+ auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
+ blink::mojom::AcceptsTypesInfo::New(
+ std::vector<blink::mojom::ChooseFileSystemEntryAcceptsOptionPtr>(),
+ /*include_accepts_all=*/true),
+ /*can_select_multiple_files=*/false);
+ auto picker_options = blink::mojom::FilePickerOptions::New(
+ blink::mojom::TypeSpecificFilePickerOptionsUnion::
+ NewOpenFilePickerOptions(std::move(open_file_picker_options)),
+ /*starting_directory_id=*/std::string(),
+ blink::mojom::FilePickerStartInOptionsUnion::NewDirectoryToken(
+ std::move(token_remote)));
+
+ base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr,
+ std::vector<blink::mojom::FileSystemAccessEntryPtr>>
+ future;
+ manager_remote->ChooseEntries(std::move(picker_options),
+ future.GetCallback());
+ ASSERT_TRUE(future.Wait());
+
+ EXPECT_EQ(blink::mojom::FileSystemAccessStatus::kInvalidArgument,
+ future.Get<0>()->status);
+ EXPECT_TRUE(future.Get<1>().empty());
+}
+
TEST_F(FileSystemAccessManagerImplTest,
ChooseEntries_OpenFile_EnterpriseBlock) {
base::FilePath test_file = dir_.GetPath().AppendASCII("foo");
diff --git a/content/browser/file_system_access/file_system_access_observer_browsertest.cc b/content/browser/file_system_access/file_system_access_observer_browsertest.cc
index b0c36f1..cdb0cae 100644
--- a/content/browser/file_system_access/file_system_access_observer_browsertest.cc
+++ b/content/browser/file_system_access/file_system_access_observer_browsertest.cc
@@ -1691,6 +1691,123 @@
blink::mojom::FileSystemAccessStatus::kInvalidArgument);
}
+// Checks that a `start_in` directory TransferToken from an unexpected origin
+// cannot be used to pick the directory the file picker opens in. Same pattern
+// as the move variant above, applied to the ChooseEntries() code path in
+// ResolveDefaultDirectory.
+IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest,
+ ChooseEntriesRefusesCrossOriginStartInToken) {
+ base::FilePath victim_dir_path;
+ base::FilePath file_in_victim_dir_path;
+ {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ ASSERT_TRUE(base::CreateTemporaryDirInDir(
+ temp_dir_.GetPath(), FILE_PATH_LITERAL("victim"), &victim_dir_path));
+ ASSERT_TRUE(base::CreateTemporaryFileInDir(victim_dir_path,
+ &file_in_victim_dir_path));
+ }
+
+ // Victim site.
+ GURL url_victim = GetURL("a.com", "/title1.html");
+ const url::Origin origin_victim = url::Origin::Create(url_victim);
+ const blink::StorageKey key_victim =
+ blink::StorageKey::CreateFirstParty(origin_victim);
+ // Attacker site. Uses the loopback host so that its origin is potentially
+ // trustworthy, which BindReceiver() requires.
+ GURL url_attacker = GetURL("/title1.html");
+ const url::Origin origin_attacker = url::Origin::Create(url_attacker);
+ const blink::StorageKey key_attacker =
+ blink::StorageKey::CreateFirstParty(origin_attacker);
+ ASSERT_NE(origin_victim, origin_attacker);
+
+ // If the picker were shown, it would select a file inside origin A's
+ // directory. `dialog_params` records whether a dialog was created at all.
+ SelectFileDialogParams dialog_params;
+ ui::SelectFileDialog::SetFactory(
+ std::make_unique<FakeSelectFileDialogFactory>(
+ std::vector<base::FilePath>{file_in_victim_dir_path},
+ &dialog_params));
+
+ // Navigate to the attacker site, whose frame issues the ChooseEntries()
+ // call.
+ ASSERT_TRUE(NavigateToURL(shell(), url_attacker));
+ RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+ ASSERT_TRUE(rfh);
+
+ auto* manager = GetManager();
+ ASSERT_TRUE(manager);
+
+ // Origin A owns the directory with read+write grants.
+ const storage::FileSystemURL victim_dir_url =
+ manager->CreateFileSystemURLFromPath(PathInfo(victim_dir_path));
+ auto victim_read_grant =
+ base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+ FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+ PathInfo(victim_dir_path));
+ auto victim_write_grant =
+ base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+ FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+ PathInfo(victim_dir_path));
+ FileSystemAccessManagerImpl::SharedHandleState victim_handle_state(
+ victim_read_grant, victim_write_grant);
+ FileSystemAccessManagerImpl::BindingContext victim_context(
+ key_victim, url_victim, rfh->GetGlobalId());
+ auto victim_dir_handle =
+ std::make_unique<FileSystemAccessDirectoryHandleImpl>(
+ manager, victim_context, victim_dir_url, victim_handle_state);
+
+ // Transfer token for origin A's directory, registered in the manager.
+ mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+ manager->CreateTransferToken(*victim_dir_handle,
+ token_remote.InitWithNewPipeAndPassReceiver());
+
+ // Bind a FileSystemAccessManager for origin B.
+ FileSystemAccessManagerImpl::BindingContext attacker_context(
+ key_attacker, url_attacker, rfh->GetGlobalId());
+ mojo::Remote<blink::mojom::FileSystemAccessManager> attacker_manager;
+ manager->BindReceiver(attacker_context,
+ attacker_manager.BindNewPipeAndPassReceiver());
+
+ // ChooseEntries() consumes transient user activation; ExecJs grants it.
+ EXPECT_TRUE(ExecJs(shell(), "true"));
+
+ // Call ChooseEntries() from origin B's manager with origin A's directory
+ // TransferToken as the `start_in` option.
+ auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
+ blink::mojom::AcceptsTypesInfo::New(
+ std::vector<blink::mojom::ChooseFileSystemEntryAcceptsOptionPtr>(),
+ /*include_accepts_all=*/true),
+ /*can_select_multiple_files=*/false);
+ auto picker_options = blink::mojom::FilePickerOptions::New(
+ blink::mojom::TypeSpecificFilePickerOptionsUnion::
+ NewOpenFilePickerOptions(std::move(open_file_picker_options)),
+ /*starting_directory_id=*/std::string(),
+ blink::mojom::FilePickerStartInOptionsUnion::NewDirectoryToken(
+ std::move(token_remote)));
+
+ base::RunLoop run_loop;
+ blink::mojom::FileSystemAccessStatus choose_entries_status;
+ size_t entry_count = 0;
+ attacker_manager->ChooseEntries(
+ std::move(picker_options),
+ base::BindLambdaForTesting(
+ [&](blink::mojom::FileSystemAccessErrorPtr result,
+ std::vector<blink::mojom::FileSystemAccessEntryPtr> entries) {
+ choose_entries_status = result->status;
+ entry_count = entries.size();
+ run_loop.Quit();
+ }));
+ run_loop.Run();
+
+ // ResolveDefaultDirectory must compare the resolved token's origin against
+ // the caller's binding-context origin and reject the mismatch with
+ // kInvalidArgument, without showing a file picker.
+ ASSERT_EQ(choose_entries_status,
+ blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+ EXPECT_EQ(entry_count, 0u);
+ EXPECT_EQ(dialog_params.type, ui::SelectFileDialog::SELECT_NONE);
+}
+
// Checks that a possible-child transfer token from a different origin cannot
// be resolved against a directory handle from the calling origin.
IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest,
Original Bug Report
FileSystemAccessManagerImpl::ChooseEntries Permissions Bypass: missing origin check on start_in.directory_token (incomplete-fix sibling of 3b8f2da9fa)
Report description
FileSystemAccessManagerImpl::ChooseEntries Permissions Bypass: missing origin check on start_in.directory_token (incomplete-fix sibling of 3b8f2da9fa)
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
Vulnerability Description
FileSystemAccessManagerImpl::ChooseEntries (the browser-side implementation of showOpenFilePicker / showSaveFilePicker / showDirectoryPicker) accepts a renderer-supplied options->start_in_options->directory_token and resolves it through ResolveTransferToken into ResolveDefaultDirectory, which calls HandleTransferTokenAsDefaultDirectory(token, info) to extract the token’s URL as the default directory of the system file picker.
The resolved token’s origin() is never compared against the calling context’s storage_key.origin().
content/browser/file_system_access/file_system_access_manager_impl.cc:613-624:
if (!options->start_in_options.is_null() &&
options->start_in_options->is_directory_token() &&
options->start_in_options->get_directory_token().is_valid()) {
auto token = std::move(options->start_in_options->get_directory_token());
options->start_in_options->set_directory_token(mojo::NullRemote());
ResolveTransferToken(
std::move(token),
base::BindOnce(&FileSystemAccessManagerImpl::ResolveDefaultDirectory,
weak_factory_.GetWeakPtr(), context, std::move(options),
std::move(wrapped_callback)));
return;
}
content/browser/file_system_access/file_system_access_manager_impl.cc:637-687 (ResolveDefaultDirectory):
PathInfo path_info;
if (resolved_directory_token) {
// Prioritize an explicitly stated directory handle to start in over an `id`
// or a well-known directory.
HandleTransferTokenAsDefaultDirectory(resolved_directory_token, path_info);
}
content/browser/file_system_access/file_system_access_manager_impl.cc:333-356 (HandleTransferTokenAsDefaultDirectory):
void HandleTransferTokenAsDefaultDirectory(
FileSystemAccessTransferTokenImpl* token,
PathInfo& info) {
// ...
DCHECK(token_url_type == storage::kFileSystemTypeLocal);
info.path = token->type() == HandleType::kFile ? token->url().path().DirName()
: token->url().path();
}
The path lifted from the cross-origin token becomes the picker’s default directory, the picker is shown to the user pre-positioned at that directory, and (if the user clicks any file inside) the resulting FileSystemFileHandle is constructed with the calling context (origin B) rather than the token’s owning origin (origin A).
This is a missed sibling of:
- 3b8f2da9fa - “FSA: Check destination directory origin in DidResolveTokenToMove” (2026-04-27)
- crbug.com/499917177 - DidResolveTokenToObserve origin check (referenced in 3b8f2da9fa commit message)
The 3b8f2da9fa commit message itself is explicit:
> “Sibling callbacks in FileSystemAccessManagerImpl that resolve destination tokens (DidResolveTransferTokenForFileHandle and DidResolveTransferTokenForDirectoryHandle) already enforce this via IsValidTransferToken; DidResolveTokenToMove was missed.”
ResolveDefaultDirectory / HandleTransferTokenAsDefaultDirectory is the next missed sibling.
The threat model is documented in-tree at manager_impl.cc:1442 (DidResolveTransferTokenForFileHandle):
> “In practice, the FileSystemAccessManager should not receive any invalid tokens. Before redeeming a token, the render process performs an origin check to ensure the token is valid. Invalid tokens indicate a code bug or a compromised render process.”
A compromised renderer of origin B that has obtained a FileSystemAccessTransferToken registered to a different origin A (e.g. via cross-origin postMessage of a FileSystemDirectoryHandle, which is permitted by the FSA spec; the token pipe survives in the receiving SerializedScriptValue’s tokens array) can send a ChooseEntries Mojo message whose start_in_options.directory_token references A’s token.
Attack Preconditions
- Compromised renderer at origin B.
- Origin B’s renderer has reached a
FileSystemAccessTransferTokenMojo pipe registered to a different origin A. The legitimate way for B to reach such a pipe is cross-origin postMessage of aFileSystemHandle, which is allowed by spec; only the browser-sideIsValidTransferToken(in the file-handle / directory-handle redemption paths) and the Blink deserializer’sGetDirectoryHandleFromTokenround-trip currently quarantine cross-origin tokens. - Origin A previously had a directory grant (e.g.
/Users/<user>/Documents/SecretCorp/) - the path that becomes the picker’s default. - Standard user activation that authorizes any normal
showOpenFilePickerfrom origin B.
No additional sandbox escape, no user activation chain beyond the picker’s normal trigger, no privileged extension.
Reproduction Steps
A unit test that drives the bug at the Mojo layer is provided as fsa_chooseentries_origin_bypass_poc.patch.
The test mirrors the in-tree MoveRefusesCrossOriginDestinationToken browsertest pattern:
- Construct a
FileSystemAccessDirectoryHandleImplforhttps://victim.example.orgover a real on-disk directory and callmanager_->CreateTransferToken(...)to register the token intransfer_tokens_. - Pin
SetFilePickerResultForTestingto a file inside that victim directory. - Issue
manager_remote_->ChooseEntries(options{start_in.directory_token = victim_token})from the fixture’s bound remote, which is bound tohttps://example.com/test.
Expected (with fix applied): the call returns kInvalidArgument and an empty entries vector.
Without the fix: ResolveTransferToken returns the victim’s TransferTokenImpl*, HandleTransferTokenAsDefaultDirectory writes the victim path into path_info, and the call eventually returns kOk plus a FileSystemAccessEntry for victim_dir/loot to the attacker’s bound remote (and would, in production, have rendered the victim’s path in the system picker dialog).
Build:
gn gen out/asan --args='is_asan=true is_debug=false symbol_level=1 is_component_build=true enable_nacl=false'
autoninja -C out/asan content_unittests
Run:
out/asan/content_unittests \
--gtest_filter=FileSystemAccessManagerImplTest.ChooseEntries_RefusesCrossOriginStartInToken
Real stdout/stderr captured via shell redirect: fsa_chooseentries_origin_bypass_test_output.txt.
Impact analysis
A compromised renderer at origin B with a TransferToken registered to a different origin A can:
- Cross-origin path disclosure: cause the system file picker to open at A’s previously-private absolute path (
/Users/<user>/...), rendering that path on the user’s screen, into accessibility/screen-share/screenshot pipelines, and into any reporter that captures dialog state. The full ancestor path is not normally web-exposed -FileSystemHandle.nameonly reveals the basename. - Cross-origin read grant on one user click: if the user clicks any file inside the pre-positioned directory,
DidChooseEntriesruns with B’sbinding_contextand constructs aFileSystemFileHandlefor B over a file inside A’s directory. The user’s click is interpreted as authorizing the picker (which is what the user expected), not as authorizing B to access A’s filesystem grant.
Severity classification:
- Vulnerability type: Permissions Bypass (primary), with Information Leak as the click-free fallback.
- Process: browser process.
- Mitigations: none in place. The Blink-side
ToMojomStartInOptionssame-origin check is bypassed by a compromised renderer that crafts the Mojo message directly; the browser must enforce.
The cause
What version of Chrome have you found the security issue in?
150.0.7831.0 (HEAD, commit 0fb7a10ddc, 2026-05-07) Stable at the time of writing: 148.0.7778.97 - also affected (bug is pre-existing, not a regression).
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Permissions Bypass