Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in FileSystem
DescriptionMissing authorization in FileSystem
ComponentFileSystem
Sub-componentFSA
Bug ClassAuthorization Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker495876543
Fix commit74bd85102d78 (chromium/src) +184/-2
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-24 (per vendor advisory)
Fix landed2026-08-06 (public git)
Disclosed2026-09-08
Patch gap33 days — fix public in source before the advisory

Background

File System Access API
a web platform API that lets a page hold live FileSystemFileHandle/FileSystemDirectoryHandle objects backed by real files, mediated in the browser process by classes like FileSystemAccessFileHandleImpl and FileSystemAccessDirectoryHandleImpl.
`FileSystemAccessTransferTokenImpl`
a browser-side, manager-registered token that serializes a file/directory handle so it can be passed between execution contexts (e.g. via postMessage), and which records the origin() that created it.
`BindingContext` / `storage_key`
the per-connection context identifying the calling renderer; context().storage_key.origin() is the origin that is actually allowed to operate on the bound handle.
`resolve()` / `isSameEntry()`
DOM methods that take another handle (as a transfer token) and return, respectively, the relative path of a child under a directory, or a boolean indicating whether two handles refer to the same entry.

Root Cause Analysis

FileSystemAccessDirectoryHandleImpl::ResolveImpl and FileSystemAccessFileHandleImpl::IsSameEntryImpl are the callbacks that run after a FileSystemAccessTransferTokenImpl supplied to resolve() / isSameEntry() has been resolved by the manager. Before the patch each callback only checked that the resolved token pointer was non-null (if (!possible_child) / if (!other)) and then proceeded to compare or resolve the token’s FileSystemURL against the handle bound to the caller — never confirming that the token belonged to the caller’s origin. Because transfer tokens can legitimately be delivered to a different execution context, a token minted by origin A could be handed to origin B and then resolved against B’s own handle, violating the same-origin authorization invariant that the sibling resolve callbacks (DidResolveTokenToMove, DidResolveTransferTokenToObserve, DidResolveTransferTokenForFileHandle, DidResolveTransferTokenForDirectoryHandle) already enforced.

The fix tightens the guard to also reject when possible_child->origin() != context().storage_key.origin() (and the analogous other->origin() check), returning kOperationFailed and an empty result before any cross-origin comparison or path resolution happens.

Key insight
The single mistake was resolving a caller-supplied transfer token without checking that the token’s origin() matches the caller’s own context().storage_key.origin(), leaving ResolveImpl and IsSameEntryImpl as the two spots that trusted a token another origin created. The fix restores the same-origin invariant by adding that origin comparison to the early-exit guard, mirroring the sibling callbacks that were already correct.

Attack Path

  1. Obtain a cross-origin handle token A cooperating (or attacker-controlled) origin A creates a FileSystemFileHandle and postMessages it to attacker origin B; internally this delivers a FileSystemAccessTransferTokenImpl registered in the manager but stamped with A’s origin.
  2. Invoke resolve()/isSameEntry() from origin B Origin B calls directoryHandle.resolve(tokenFromA) or fileHandle.isSameEntry(tokenFromA) on a handle B legitimately owns, passing A’s token.
  3. Reach the unguarded callback The manager resolves the token and invokes ResolveImpl/IsSameEntryImpl, whose pre-patch check only rejected a null pointer, so B’s call proceeds against A’s token.
  4. Leak cross-origin relationship resolve() returns the relative path components locating A’s file under B’s directory, and isSameEntry() returns whether B’s handle and A’s token point to the same underlying file, disclosing cross-origin file-location/identity information to B (inferred, based on the methods’ return values).

Impact Assessment

An attacker web origin holding another origin’s transfer token can defeat the File System Access same-origin authorization boundary in the browser process, learning whether/where a foreign origin’s file sits under a directory it controls (resolve()) or whether two handles from different origins name the same file (isSameEntry()) — a cross-origin information disclosure (inferred). Preconditions are that the attacker receive a valid transfer token from another origin (e.g. via postMessage) and possess a handle of its own to compare against; no memory corruption is involved. The advisory classifies this only as “Missing authorization in FileSystem”; it states no exploited-in-the-wild status, and any escalation beyond the information leak the methods return is not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
if
content/browser/file_system_access/file_system_access_directory_handle_impl.cc
modified
if
content/browser/file_system_access/file_system_access_file_handle_impl.cc
modified

Files Changed

  • content/browser/file_system_access/file_system_access_observer_browsertest.cc
  • content/browser/file_system_access/file_system_access_file_handle_impl.cc
  • content/browser/file_system_access/file_system_access_directory_handle_impl.cc

Audit Directions

  • Resolved-token callbacks lacking an origin check
    Audit every DidResolve*/*Impl callback that receives a resolved FileSystemAccessTransferTokenImpl and confirm each compares the token’s origin() against context().storage_key.origin() before use, since sibling callbacks were correct while these two were missed.
  • Trusting serialized cross-context capabilities
    Wherever a capability can be transferred between execution contexts (transfer tokens, mojo pending remotes, cloned handles), verify the receiving endpoint re-authorizes the caller’s origin rather than assuming a valid token implies authorized use.
  • Null-only guards on security-sensitive inputs
    Flag if (!ptr) guards on attacker-influenced resolved objects that then feed comparisons or path lookups; a mere non-null check is often an incomplete authorization gate that should also validate origin/identity.
From 74bd85102d783a72b0fdbac99adfc1cc134b372d Mon Sep 17 00:00:00 2001
From: Mike Taylor <miketaylr@chromium.org>
Date: Thu, 06 Aug 2026 11:06:37 -0700
Subject: [PATCH] FSA: Check token origin in Resolve() and IsSameEntry()

FileSystemAccessDirectoryHandleImpl::ResolveImpl and
FileSystemAccessFileHandleImpl::IsSameEntryImpl resolve the
FileSystemAccessTransferToken passed to resolve() /
isSameEntry() but do not verify that the resolved token's
origin matches the calling context's origin. Sibling callbacks
that resolve transfer tokens (DidResolveTokenToMove,
DidResolveTransferTokenToObserve,
DidResolveTransferTokenForFileHandle and
DidResolveTransferTokenForDirectoryHandle) already enforce this
check; ResolveImpl and IsSameEntryImpl were missed.

Add an origin check mirroring those sibling callbacks: reject
the call with kOperationFailed when the resolved token is null
or its origin differs from context().storage_key.origin().

Also add content_browsertests
(FileSystemAccessObserverCrossOriginTokenBypassTest.
ResolveRefusesCrossOriginChildToken and
IsSameEntryRefusesCrossOriginToken) that drive the browser-side
interfaces directly with a foreign-origin transfer token and
verify the calls are rejected.

TAG=agy
CONV=680ba172-7c3e-488d-bcc5-7e67aaff5c41

Bug: 495876543
Change-Id: I36968b94f857e1a5119f56930d6db8c8a373a266
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8202508
Reviewed-by: Rahul Singh <rahsin@microsoft.com>
Commit-Queue: Mike Taylor <miketaylr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1675118}
---
diff --git a/content/browser/file_system_access/file_system_access_directory_handle_impl.cc b/content/browser/file_system_access/file_system_access_directory_handle_impl.cc
index 74be365e..9ca98fc 100644
--- a/content/browser/file_system_access/file_system_access_directory_handle_impl.cc
+++ b/content/browser/file_system_access/file_system_access_directory_handle_impl.cc
@@ -557,7 +557,8 @@
     FileSystemAccessTransferTokenImpl* possible_child) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
-  if (!possible_child) {
+  if (!possible_child ||
+      possible_child->origin() != context().storage_key.origin()) {
     std::move(callback).Run(
         file_system_access_error::FromStatus(
             blink::mojom::FileSystemAccessStatus::kOperationFailed),
diff --git a/content/browser/file_system_access/file_system_access_file_handle_impl.cc b/content/browser/file_system_access/file_system_access_file_handle_impl.cc
index 8a987f8..984b099 100644
--- a/content/browser/file_system_access/file_system_access_file_handle_impl.cc
+++ b/content/browser/file_system_access/file_system_access_file_handle_impl.cc
@@ -474,7 +474,7 @@
     IsSameEntryCallback callback,
     FileSystemAccessTransferTokenImpl* other) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
-  if (!other) {
+  if (!other || other->origin() != context().storage_key.origin()) {
     std::move(callback).Run(
         file_system_access_error::FromStatus(
             blink::mojom::FileSystemAccessStatus::kOperationFailed),
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 3c294fa0..b0c36f1 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,4 +1691,185 @@
             blink::mojom::FileSystemAccessStatus::kInvalidArgument);
 }
 
+// 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,
+                       ResolveRefusesCrossOriginChildToken) {
+  base::FilePath parent_dir_path;
+  base::FilePath child_file_path;
+  {
+    base::ScopedAllowBlockingForTesting allow_blocking;
+    ASSERT_TRUE(base::CreateTemporaryDirInDir(
+        temp_dir_.GetPath(), FILE_PATH_LITERAL("parent"), &parent_dir_path));
+    ASSERT_TRUE(
+        base::CreateTemporaryFileInDir(parent_dir_path, &child_file_path));
+  }
+
+  // First origin site.
+  GURL url_first = GetURL("a.com", "/title1.html");
+  const url::Origin origin_first = url::Origin::Create(url_first);
+  const blink::StorageKey key_first =
+      blink::StorageKey::CreateFirstParty(origin_first);
+  // Second origin site.
+  GURL url_second = GetURL("b.com", "/title1.html");
+  const url::Origin origin_second = url::Origin::Create(url_second);
+  const blink::StorageKey key_second =
+      blink::StorageKey::CreateFirstParty(origin_second);
+  ASSERT_NE(origin_first, origin_second);
+
+  ASSERT_TRUE(NavigateToURL(shell(), url_first));
+  RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+  ASSERT_TRUE(rfh);
+
+  auto* manager = GetManager();
+  ASSERT_TRUE(manager);
+
+  // First origin owns a file handle whose path is inside second origin's
+  // directory.
+  const storage::FileSystemURL first_file_url =
+      manager->CreateFileSystemURLFromPath(PathInfo(child_file_path));
+  auto first_read_grant =
+      base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+          FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+          PathInfo(child_file_path));
+  auto first_write_grant =
+      base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+          FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+          PathInfo(child_file_path));
+  FileSystemAccessManagerImpl::SharedHandleState first_handle_state(
+      first_read_grant, first_write_grant);
+  FileSystemAccessManagerImpl::BindingContext first_context(
+      key_first, url_first, rfh->GetGlobalId());
+  auto first_file_handle = std::make_unique<FileSystemAccessFileHandleImpl>(
+      manager, first_context, first_file_url, /*display_name=*/"",
+      first_handle_state);
+
+  // Transfer token for first origin's file, registered in the manager.
+  mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+  manager->CreateTransferToken(*first_file_handle,
+                               token_remote.InitWithNewPipeAndPassReceiver());
+
+  // Second origin owns the parent directory handle.
+  const storage::FileSystemURL second_dir_url =
+      manager->CreateFileSystemURLFromPath(PathInfo(parent_dir_path));
+  auto second_read_grant =
+      base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+          FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+          PathInfo(parent_dir_path));
+  auto second_write_grant =
+      base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+          FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+          PathInfo(parent_dir_path));
+  FileSystemAccessManagerImpl::SharedHandleState second_handle_state(
+      second_read_grant, second_write_grant);
+  FileSystemAccessManagerImpl::BindingContext second_context(
+      key_second, url_second, rfh->GetGlobalId());
+  auto second_dir_handle =
+      std::make_unique<FileSystemAccessDirectoryHandleImpl>(
+          manager, second_context, second_dir_url, second_handle_state);
+
+  // Call Resolve() on second origin's directory handle with first origin's
+  // file transfer token as the possible child.
+  base::RunLoop run_loop;
+  blink::mojom::FileSystemAccessStatus resolve_status;
+  std::optional<std::vector<std::string>> resolve_path;
+  static_cast<blink::mojom::FileSystemAccessDirectoryHandle*>(
+      second_dir_handle.get())
+      ->Resolve(std::move(token_remote),
+                base::BindLambdaForTesting(
+                    [&](blink::mojom::FileSystemAccessErrorPtr result,
+                        const std::optional<std::vector<std::string>>& path) {
+                      resolve_status = result->status;
+                      resolve_path = path;
+                      run_loop.Quit();
+                    }));
+  run_loop.Run();
+
+  // ResolveImpl must reject the mismatch when the token origin does not match
+  // the caller's binding context origin.
+  EXPECT_EQ(resolve_status,
+            blink::mojom::FileSystemAccessStatus::kOperationFailed);
+  EXPECT_FALSE(resolve_path.has_value());
+}
+
+// Checks that a transfer token from a different origin cannot be used with
+// FileSystemFileHandle::isSameEntry() to check against another origin's handle.
+IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest,
+                       IsSameEntryRefusesCrossOriginToken) {
+  base::FilePath file_path;
+  {
+    base::ScopedAllowBlockingForTesting allow_blocking;
+    ASSERT_TRUE(
+        base::CreateTemporaryFileInDir(temp_dir_.GetPath(), &file_path));
+  }
+
+  // First origin site.
+  GURL url_first = GetURL("a.com", "/title1.html");
+  const url::Origin origin_first = url::Origin::Create(url_first);
+  const blink::StorageKey key_first =
+      blink::StorageKey::CreateFirstParty(origin_first);
+  // Second origin site.
+  GURL url_second = GetURL("b.com", "/title1.html");
+  const url::Origin origin_second = url::Origin::Create(url_second);
+  const blink::StorageKey key_second =
+      blink::StorageKey::CreateFirstParty(origin_second);
+  ASSERT_NE(origin_first, origin_second);
+
+  ASSERT_TRUE(NavigateToURL(shell(), url_first));
+  RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+  ASSERT_TRUE(rfh);
+
+  auto* manager = GetManager();
+  ASSERT_TRUE(manager);
+
+  const storage::FileSystemURL file_url =
+      manager->CreateFileSystemURLFromPath(PathInfo(file_path));
+  auto read_grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+      FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+      PathInfo(file_path));
+  auto write_grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+      FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+      PathInfo(file_path));
+  FileSystemAccessManagerImpl::SharedHandleState handle_state(read_grant,
+                                                              write_grant);
+
+  // Both origins independently hold a file handle to the same underlying file.
+  FileSystemAccessManagerImpl::BindingContext first_context(
+      key_first, url_first, rfh->GetGlobalId());
+  auto first_file_handle = std::make_unique<FileSystemAccessFileHandleImpl>(
+      manager, first_context, file_url, /*display_name=*/"", handle_state);
+
+  FileSystemAccessManagerImpl::BindingContext second_context(
+      key_second, url_second, rfh->GetGlobalId());
+  auto second_file_handle = std::make_unique<FileSystemAccessFileHandleImpl>(
+      manager, second_context, file_url, /*display_name=*/"", handle_state);
+
+  // Transfer token for first origin's file, registered in the manager.
+  mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+  manager->CreateTransferToken(*first_file_handle,
+                               token_remote.InitWithNewPipeAndPassReceiver());
+
+  // Call IsSameEntry() on second origin's file handle with first origin's
+  // transfer token.
+  base::RunLoop run_loop;
+  blink::mojom::FileSystemAccessStatus status;
+  bool is_same = false;
+  static_cast<blink::mojom::FileSystemAccessFileHandle*>(
+      second_file_handle.get())
+      ->IsSameEntry(
+          std::move(token_remote),
+          base::BindLambdaForTesting(
+              [&](blink::mojom::FileSystemAccessErrorPtr result, bool same) {
+                status = result->status;
+                is_same = same;
+                run_loop.Quit();
+              }));
+  run_loop.Run();
+
+  // IsSameEntryImpl must reject the mismatch when the token origin does not
+  // match the caller's binding context origin.
+  EXPECT_EQ(status, blink::mojom::FileSystemAccessStatus::kOperationFailed);
+  EXPECT_FALSE(is_same);
+}
+
 }  // namespace content
Loading diff…

Regression Test / PoC

shipped with the fix
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 3c294fa0..b0c36f1 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,4 +1691,185 @@
             blink::mojom::FileSystemAccessStatus::kInvalidArgument);
 }
 
+// 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,
+                       ResolveRefusesCrossOriginChildToken) {
+  base::FilePath parent_dir_path;
+  base::FilePath child_file_path;
+  {
+    base::ScopedAllowBlockingForTesting allow_blocking;
+    ASSERT_TRUE(base::CreateTemporaryDirInDir(
+        temp_dir_.GetPath(), FILE_PATH_LITERAL("parent"), &parent_dir_path));
+    ASSERT_TRUE(
+        base::CreateTemporaryFileInDir(parent_dir_path, &child_file_path));
+  }
+
+  // First origin site.
+  GURL url_first = GetURL("a.com", "/title1.html");
+  const url::Origin origin_first = url::Origin::Create(url_first);
+  const blink::StorageKey key_first =
+      blink::StorageKey::CreateFirstParty(origin_first);
+  // Second origin site.
+  GURL url_second = GetURL("b.com", "/title1.html");
+  const url::Origin origin_second = url::Origin::Create(url_second);
+  const blink::StorageKey key_second =
+      blink::StorageKey::CreateFirstParty(origin_second);
+  ASSERT_NE(origin_first, origin_second);
+
+  ASSERT_TRUE(NavigateToURL(shell(), url_first));
+  RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+  ASSERT_TRUE(rfh);
+
+  auto* manager = GetManager();
+  ASSERT_TRUE(manager);
+
+  // First origin owns a file handle whose path is inside second origin's
+  // directory.
+  const storage::FileSystemURL first_file_url =
+      manager->CreateFileSystemURLFromPath(PathInfo(child_file_path));
+  auto first_read_grant =
+      base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+          FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+          PathInfo(child_file_path));
+  auto first_write_grant =
+      base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+          FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+          PathInfo(child_file_path));
+  FileSystemAccessManagerImpl::SharedHandleState first_handle_state(
+      first_read_grant, first_write_grant);
+  FileSystemAccessManagerImpl::BindingContext first_context(
+      key_first, url_first, rfh->GetGlobalId());
+  auto first_file_handle = std::make_unique<FileSystemAccessFileHandleImpl>(
+      manager, first_context, first_file_url, /*display_name=*/"",
+      first_handle_state);
+
+  // Transfer token for first origin's file, registered in the manager.
+  mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+  manager->CreateTransferToken(*first_file_handle,
+                               token_remote.InitWithNewPipeAndPassReceiver());
+
+  // Second origin owns the parent directory handle.
+  const storage::FileSystemURL second_dir_url =
+      manager->CreateFileSystemURLFromPath(PathInfo(parent_dir_path));
+  auto second_read_grant =
+      base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+          FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+          PathInfo(parent_dir_path));
+  auto second_write_grant =
+      base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+          FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+          PathInfo(parent_dir_path));
+  FileSystemAccessManagerImpl::SharedHandleState second_handle_state(
+      second_read_grant, second_write_grant);
+  FileSystemAccessManagerImpl::BindingContext second_context(
+      key_second, url_second, rfh->GetGlobalId());
+  auto second_dir_handle =
+      std::make_unique<FileSystemAccessDirectoryHandleImpl>(
+          manager, second_context, second_dir_url, second_handle_state);
+
+  // Call Resolve() on second origin's directory handle with first origin's
+  // file transfer token as the possible child.
+  base::RunLoop run_loop;
+  blink::mojom::FileSystemAccessStatus resolve_status;
+  std::optional<std::vector<std::string>> resolve_path;
+  static_cast<blink::mojom::FileSystemAccessDirectoryHandle*>(
+      second_dir_handle.get())
+      ->Resolve(std::move(token_remote),
+                base::BindLambdaForTesting(
+                    [&](blink::mojom::FileSystemAccessErrorPtr result,
+                        const std::optional<std::vector<std::string>>& path) {
+                      resolve_status = result->status;
+                      resolve_path = path;
+                      run_loop.Quit();
+                    }));
+  run_loop.Run();
+
+  // ResolveImpl must reject the mismatch when the token origin does not match
+  // the caller's binding context origin.
+  EXPECT_EQ(resolve_status,
+            blink::mojom::FileSystemAccessStatus::kOperationFailed);
+  EXPECT_FALSE(resolve_path.has_value());
+}
+
+// Checks that a transfer token from a different origin cannot be used with
+// FileSystemFileHandle::isSameEntry() to check against another origin's handle.
+IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest,
+                       IsSameEntryRefusesCrossOriginToken) {
+  base::FilePath file_path;
+  {
+    base::ScopedAllowBlockingForTesting allow_blocking;
+    ASSERT_TRUE(
+        base::CreateTemporaryFileInDir(temp_dir_.GetPath(), &file_path));
+  }
+
+  // First origin site.
+  GURL url_first = GetURL("a.com", "/title1.html");
+  const url::Origin origin_first = url::Origin::Create(url_first);
+  const blink::StorageKey key_first =
+      blink::StorageKey::CreateFirstParty(origin_first);
+  // Second origin site.
+  GURL url_second = GetURL("b.com", "/title1.html");
+  const url::Origin origin_second = url::Origin::Create(url_second);
+  const blink::StorageKey key_second =
+      blink::StorageKey::CreateFirstParty(origin_second);
+  ASSERT_NE(origin_first, origin_second);
+
+  ASSERT_TRUE(NavigateToURL(shell(), url_first));
+  RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+  ASSERT_TRUE(rfh);
+
+  auto* manager = GetManager();
+  ASSERT_TRUE(manager);
+
+  const storage::FileSystemURL file_url =
+      manager->CreateFileSystemURLFromPath(PathInfo(file_path));
+  auto read_grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+      FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+      PathInfo(file_path));
+  auto write_grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+      FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+      PathInfo(file_path));
+  FileSystemAccessManagerImpl::SharedHandleState handle_state(read_grant,
+                                                              write_grant);
+
+  // Both origins independently hold a file handle to the same underlying file.
+  FileSystemAccessManagerImpl::BindingContext first_context(
+      key_first, url_first, rfh->GetGlobalId());
+  auto first_file_handle = std::make_unique<FileSystemAccessFileHandleImpl>(
+      manager, first_context, file_url, /*display_name=*/"", handle_state);
+
+  FileSystemAccessManagerImpl::BindingContext second_context(
+      key_second, url_second, rfh->GetGlobalId());
+  auto second_file_handle = std::make_unique<FileSystemAccessFileHandleImpl>(
+      manager, second_context, file_url, /*display_name=*/"", handle_state);
+
+  // Transfer token for first origin's file, registered in the manager.
+  mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+  manager->CreateTransferToken(*first_file_handle,
+                               token_remote.InitWithNewPipeAndPassReceiver());
+
+  // Call IsSameEntry() on second origin's file handle with first origin's
+  // transfer token.
+  base::RunLoop run_loop;
+  blink::mojom::FileSystemAccessStatus status;
+  bool is_same = false;
+  static_cast<blink::mojom::FileSystemAccessFileHandle*>(
+      second_file_handle.get())
+      ->IsSameEntry(
+          std::move(token_remote),
+          base::BindLambdaForTesting(
+              [&](blink::mojom::FileSystemAccessErrorPtr result, bool same) {
+                status = result->status;
+                is_same = same;
+                run_loop.Quit();
+              }));
+  run_loop.Run();
+
+  // IsSameEntryImpl must reject the mismatch when the token origin does not
+  // match the caller's binding context origin.
+  EXPECT_EQ(status, blink::mojom::FileSystemAccessStatus::kOperationFailed);
+  EXPECT_FALSE(is_same);
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

reported by vi...@google.com

Potential missing origin check in File System Access API transfer tokens

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: Several Mojo methods in the File System Access API fail to verify that a provided transfer token belongs to the caller’s origin. A compromised renderer can intercept a token sent via postMessage from another origin and redeem it via methods like Move, Resolve, or Observe. This potentially allows unauthorized cross-origin file operations, such as writing to a victim’s directory.

Affected files:

  • content/browser/file_system_access/file_system_access_directory_handle_impl.cc
  • content/browser/file_system_access/file_system_access_file_handle_impl.cc
  • content/browser/file_system_access/file_system_access_handle_base.cc
  • content/browser/file_system_access/file_system_access_observer_host.cc

Estimated timestamp from git blame: 2025-08-25

Note: The following analysis and steps are suggested by an AI agent. A working proof-of-concept has not been executed, but the logical flow has been traced through the codebase.

Root Cause

In the File System Access API, when handles are passed via postMessage, they are serialized into a FileSystemAccessTransferToken. When these tokens are normally redeemed (e.g., via GetFileHandleFromToken), the browser enforces an origin check via IsValidTransferToken() to ensure the calling renderer’s origin matches the origin embedded in the token.

However, several Mojo API endpoints take a PendingRemote<FileSystemAccessTransferToken> as an argument and resolve it using FileSystemAccessManagerImpl::ResolveTransferToken(). This method retrieves the token via its unguessable ID but fails to perform any origin validation. The resulting FileSystemAccessTransferTokenImpl is then directly acted upon.

The following methods are affected:

  1. FileSystemAccessHandleBase::DoMove: Passes the token to DidResolveTokenToMove. An attacker can use a victim’s directory token as the destination, potentially moving their own files into the victim’s directory, bypassing cross-origin write restrictions.
  2. FileSystemAccessDirectoryHandleImpl::Resolve: Passes the token to ResolveImpl. Can be used to disclose relative paths of a victim’s files.
  3. FileSystemAccessObserverHost::Observe: Passes the token to DidResolveTransferTokenToObserve. Can be used to spy on file changes in a victim’s handle.
  4. FileSystemAccessFileHandleImpl::IsSameEntry: Passes the token to IsSameEntryImpl. Can be used to verify if an attacker’s file handle matches a victim’s.

Potential Exploitation Steps (e.g., Cross-Origin Write via Move)

  1. Victim Grants Access: A victim origin (https://victim.com) requests and is granted access to a local directory (e.g., /home/user/VictimDir/) via showDirectoryPicker().
  2. Token Transfer: The victim web application inadvertently or intentionally shares this directory handle with an attacker origin (https://attacker.com) via window.postMessage(directoryHandle, '*').
  3. Token Interception: The attacker has compromised the renderer process for https://attacker.com. Instead of letting the browser reject standard deserialization, the attacker intercepts the raw Mojo PendingRemote<FileSystemAccessTransferToken> IPC message.
  4. Attacker Context: The compromised attacker renderer obtains a legitimate file handle for one of its own files (e.g., /home/user/AttackerDir/evil.txt).
  5. The Exploit Call: The attacker issues a raw Mojo call to their file handle’s Move method, passing the victim’s intercepted PendingRemote<FileSystemAccessTransferToken> as the destination directory, and "pwned.txt" as the new name.
  6. Browser Processing: In the browser process, FileSystemAccessHandleBase::DoMove calls manager()->ResolveTransferToken(). The token is successfully resolved without an origin check.
  7. Unauthorized Write: DidResolveTokenToMove checks the write permission of the resolved token. Because the GetStatus() check on the token’s grant evaluates the original victim’s granted permissions, it passes. The attacker’s file is moved into the victim’s directory, achieving an unauthorized cross-origin file write.

Suggested Fix

Update FileSystemAccessManagerImpl::ResolveTransferToken() (or its DoResolveTransferToken internal helper) to accept the calling binding_context.storage_key.origin() and enforce IsValidTransferToken() before returning the resolved token. Alternatively, enforce the IsValidTransferToken() origin check individually in all vulnerable callbacks (DidResolveTokenToMove, ResolveImpl, DidResolveTransferTokenToObserve, IsSameEntryImpl).

Evaluated with Chrome root at commit: bb48272cafb7e24c93f55ef40da398cd206ee651


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker