CVE-2026-87611
Overview
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_directory_handle_impl.cccontent/browser/file_system_access/file_system_access_file_handle_impl.cccontent/browser/file_system_access/file_system_access_observer_browsertest.cc
Patch
From 74bd85102d783a72b0fdbac99adfc1cc134b372d Mon Sep 17 00:00:00 2001 From: Mike Taylor <[email protected]> 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 <[email protected]> Commit-Queue: Mike Taylor <[email protected]> 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);
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.