Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in FileSystem
DescriptionMissing authorization in FileSystem
ComponentFileSystem
Sub-componentBlink>Storage>FileSystem
Bug ClassAuthorization Error
Tracker510773353
Fix commit76f1225c09c1 (chromium/src) +197/-0
CISA KEVNot listed
Creditedpakhunov.anton.n
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
content/browser/file_system_access/file_system_access_manager_impl.cc
modified

Files Changed

  • content/browser/file_system_access/file_system_access_observer_browsertest.cc
  • content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
  • content/browser/file_system_access/file_system_access_manager_impl.cc
From 76f1225c09c11d6ca9c958c05577772e461f28bb Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <[email protected]>
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 <[email protected]>
Reviewed-by: Ming-Ying Chung <[email protected]>
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,
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 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,
Loading diff…

Original Bug Report

reported by [email protected]

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?

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/file_system_access/file_system_access_manager_impl.cc;l=637


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 FileSystemAccessTransferToken Mojo pipe registered to a different origin A. The legitimate way for B to reach such a pipe is cross-origin postMessage of a FileSystemHandle, which is allowed by spec; only the browser-side IsValidTransferToken (in the file-handle / directory-handle redemption paths) and the Blink deserializer’s GetDirectoryHandleFromToken round-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 showOpenFilePicker from 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:

  1. Construct a FileSystemAccessDirectoryHandleImpl for https://victim.example.org over a real on-disk directory and call manager_->CreateTransferToken(...) to register the token in transfer_tokens_.
  2. Pin SetFilePickerResultForTesting to a file inside that victim directory.
  3. Issue manager_remote_->ChooseEntries(options{start_in.directory_token = victim_token}) from the fixture’s bound remote, which is bound to https://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:

  1. 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.name only reveals the basename.
  2. Cross-origin read grant on one user click: if the user clicks any file inside the pre-positioned directory, DidChooseEntries runs with B’s binding_context and constructs a FileSystemFileHandle for 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 ToMojomStartInOptions same-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).

No, it is not related to a crash.

Choose the type of vulnerability

Permissions Bypass

View on issue tracker