CVE-2026-87611
Overview
Background
- File System Access API
- a web platform API that lets a page hold live
FileSystemFileHandle/FileSystemDirectoryHandleobjects backed by real files, mediated in the browser process by classes likeFileSystemAccessFileHandleImplandFileSystemAccessDirectoryHandleImpl. - `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 theorigin()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.
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
- Obtain a cross-origin handle token
A cooperating (or attacker-controlled) origin A creates a
FileSystemFileHandleand postMessages it to attacker origin B; internally this delivers aFileSystemAccessTransferTokenImplregistered in the manager but stamped with A’s origin. - Invoke resolve()/isSameEntry() from origin B
Origin B calls
directoryHandle.resolve(tokenFromA)orfileHandle.isSameEntry(tokenFromA)on a handle B legitimately owns, passing A’s token. - 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. - Leak cross-origin relationship
resolve()returns the relative path components locating A’s file under B’s directory, andisSameEntry()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
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
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/file_system_access/file_system_access_directory_handle_impl.cc |
modified | |
ifcontent/browser/file_system_access/file_system_access_file_handle_impl.cc |
modified |
Files Changed
content/browser/file_system_access/file_system_access_observer_browsertest.cccontent/browser/file_system_access/file_system_access_file_handle_impl.cccontent/browser/file_system_access/file_system_access_directory_handle_impl.cc
Audit Directions
- Resolved-token callbacks lacking an origin checkAudit every
DidResolve*/*Implcallback that receives a resolvedFileSystemAccessTransferTokenImpland confirm each compares the token’sorigin()againstcontext().storage_key.origin()before use, since sibling callbacks were correct while these two were missed. - Trusting serialized cross-context capabilitiesWherever 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 inputsFlag
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.
Patch
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
Regression Test / PoC
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
Original Bug Report
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.cccontent/browser/file_system_access/file_system_access_file_handle_impl.cccontent/browser/file_system_access/file_system_access_handle_base.cccontent/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:
FileSystemAccessHandleBase::DoMove: Passes the token toDidResolveTokenToMove. 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.FileSystemAccessDirectoryHandleImpl::Resolve: Passes the token toResolveImpl. Can be used to disclose relative paths of a victim’s files.FileSystemAccessObserverHost::Observe: Passes the token toDidResolveTransferTokenToObserve. Can be used to spy on file changes in a victim’s handle.FileSystemAccessFileHandleImpl::IsSameEntry: Passes the token toIsSameEntryImpl. 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)
- Victim Grants Access: A victim origin (
https://victim.com) requests and is granted access to a local directory (e.g.,/home/user/VictimDir/) viashowDirectoryPicker(). - Token Transfer: The victim web application inadvertently or intentionally shares this directory handle with an attacker origin (
https://attacker.com) viawindow.postMessage(directoryHandle, '*'). - 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 MojoPendingRemote<FileSystemAccessTransferToken>IPC message. - Attacker Context: The compromised attacker renderer obtains a legitimate file handle for one of its own files (e.g.,
/home/user/AttackerDir/evil.txt). - The Exploit Call: The attacker issues a raw Mojo call to their file handle’s
Movemethod, passing the victim’s interceptedPendingRemote<FileSystemAccessTransferToken>as the destination directory, and"pwned.txt"as the new name. - Browser Processing: In the browser process,
FileSystemAccessHandleBase::DoMovecallsmanager()->ResolveTransferToken(). The token is successfully resolved without an origin check. - Unauthorized Write:
DidResolveTokenToMovechecks the write permission of the resolved token. Because theGetStatus()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.