CVE-2026-11078
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
FileSystemAccessObserverCrossOriginTokenBypassTestcontent/browser/file_system_access/file_system_access_observer_browsertest.cc |
modified | |
ifcontent/browser/file_system_access/file_system_access_observer_host.cc |
modified |
Files Changed
content/browser/file_system_access/file_system_access_observer_browsertest.cccontent/browser/file_system_access/file_system_access_observer_host.cc
Patch
From 12f2a15d6550acdb736b12c5a394002c1dcd1e80 Mon Sep 17 00:00:00 2001 From: Fergal Daly <[email protected]> Date: Wed, 22 Apr 2026 21:16:34 -0700 Subject: [PATCH] Fix a cross-site leak of file-monitoring capability. If site-A has permission to monitor a file or directory then a compromised renderer on site-B can also monitor it. The fix is to check that the site associated with the final resolved token is the current site before granting access. Fixed: 499917177 Change-Id: I815a1a3dbf382b71758565b8ca035a3da5547eb0 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7784855 Commit-Queue: Fergal Daly <[email protected]> Reviewed-by: Ming-Ying Chung <[email protected]> Cr-Commit-Position: refs/heads/main@{#1619316} --- 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 be7bca9..1215895 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 @@ -8,10 +8,14 @@ #include "base/files/file_util.h" #include "base/files/scoped_temp_dir.h" #include "base/json/values_util.h" +#include "base/test/bind.h" #include "base/test/metrics/histogram_tester.h" #include "base/test/test_timeouts.h" #include "base/win/windows_version.h" #include "build/buildflag.h" +#include "content/browser/file_system_access/file_system_access_directory_handle_impl.h" +#include "content/browser/file_system_access/file_system_access_manager_impl.h" +#include "content/browser/file_system_access/fixed_file_system_access_permission_grant.h" #include "content/browser/web_contents/web_contents_impl.h" #include "content/public/browser/render_frame_host.h" #include "content/public/common/content_switches.h" @@ -22,6 +26,7 @@ #include "content/public/test/content_browser_test_utils.h" #include "content/public/test/file_system_chooser_test_helpers.h" #include "content/shell/browser/shell.h" +#include "net/dns/mock_host_resolver.h" #include "net/test/embedded_test_server/embedded_test_server.h" #include "testing/gtest/include/gtest/gtest.h" @@ -1467,4 +1472,122 @@ #endif // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS) && // !BUILDFLAG(IS_FUCHSIA) +using HandleType = FileSystemAccessPermissionContext::HandleType; + +class FileSystemAccessObserverCrossOriginTokenBypassTest + : public FileSystemAccessObserverBrowserTestBase { + public: + void SetUpOnMainThread() override { + host_resolver()->AddRule("*", "127.0.0.1"); + FileSystemAccessObserverBrowserTestBase::SetUpOnMainThread(); + } + + FileSystemAccessManagerImpl* GetManager() { + auto* partition = + static_cast<StoragePartitionImpl*>(shell() + ->web_contents() + ->GetBrowserContext() + ->GetDefaultStoragePartition()); + return partition->GetFileSystemAccessManager(); + } + + GURL GetURL(std::string_view relative_url) { + return embedded_test_server()->GetURL(relative_url); + } + + GURL GetURL(std::string_view hostname, std::string_view relative_url) { + return embedded_test_server()->GetURL(hostname, relative_url); + } +}; + +// Checks that tokens from an unexpected origin cannot be used to create an +// observer. https://crbug.com/499917177 +IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest, + ObserveRefusesCrossOriginToken) { + base::FilePath dir_path; + { + base::ScopedAllowBlockingForTesting allow_blocking; + ASSERT_TRUE(base::CreateTemporaryDirInDir( + temp_dir_.GetPath(), FILE_PATH_LITERAL("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. + GURL url_attacker = GetURL("b.com", "/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); + + // Navigate to get a valid RFH (used for binding contexts). + ASSERT_TRUE(NavigateToURL(shell(), url_victim)); + RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame(); + ASSERT_TRUE(rfh); + + auto* manager = GetManager(); + ASSERT_TRUE(manager); + + const storage::FileSystemURL dir_url = + manager->CreateFileSystemURLFromPath(PathInfo(dir_path)); + + auto read_grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>( + FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED, + PathInfo(dir_path)); + auto write_grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>( + FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED, + PathInfo(dir_path)); + FileSystemAccessManagerImpl::SharedHandleState handle_state(read_grant, + write_grant); + + // Create a directory handle owned by origin A. + FileSystemAccessManagerImpl::BindingContext victim_context( + key_victim, url_victim, rfh->GetGlobalId()); + auto dir_handle = std::make_unique<FileSystemAccessDirectoryHandleImpl>( + manager, victim_context, dir_url, handle_state); + + // Create a registered transfer token from the directory handle. + // Using CreateTransferToken ensures the token is inserted into the + // manager's transfer_tokens_ map so that ResolveTransferToken can find it. + mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote; + manager->CreateTransferToken(*dir_handle, + token_remote.InitWithNewPipeAndPassReceiver()); + + // Create a context with B's key but A's URL. + FileSystemAccessManagerImpl::BindingContext attacker_context( + key_attacker, url_victim, rfh->GetGlobalId()); + + // Bind an ObserverHost with origin B's context. + mojo::Remote<blink::mojom::FileSystemAccessObserverHost> observer_host; + manager->watcher_manager().BindObserverHost( + attacker_context, observer_host.BindNewPipeAndPassReceiver()); + + // --- The exploit: Observe() with A's token from B's host --- + base::RunLoop run_loop; + blink::mojom::FileSystemAccessStatus observe_status; + observer_host->Observe( + std::move(token_remote), /*is_recursive=*/true, + base::BindLambdaForTesting( + [&](blink::mojom::FileSystemAccessErrorPtr result, + mojo::PendingReceiver<blink::mojom::FileSystemAccessObserver> + receiver) { + observe_status = result->status; + run_loop.Quit(); + })); + run_loop.Run(); + + // THE BUG: Observe() succeeds despite origin mismatch. + // + // DidResolveTransferTokenToObserve does not call IsValidTransferToken + // to check the token origin against the binding context origin. + // Compare with DidResolveTransferTokenForFileHandle (manager_impl.cc:1442) + // and DidResolveTransferTokenForDirectoryHandle (manager_impl.cc:1467) + // which both call IsValidTransferToken and reject cross-origin tokens. + ASSERT_EQ(observe_status, + blink::mojom::FileSystemAccessStatus::kInvalidArgument); +} + } // namespace content diff --git a/content/browser/file_system_access/file_system_access_observer_host.cc b/content/browser/file_system_access/file_system_access_observer_host.cc index abd4225..a3afbb6 100644 --- a/content/browser/file_system_access/file_system_access_observer_host.cc +++ b/content/browser/file_system_access/file_system_access_observer_host.cc @@ -75,7 +75,8 @@ FileSystemAccessTransferTokenImpl* resolved_token) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); - if (!resolved_token) { + if (!resolved_token || + resolved_token->origin() != binding_context_.storage_key.origin()) { std::move(callback).Run( file_system_access_error::FromStatus( blink::mojom::FileSystemAccessStatus::kInvalidArgument),
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 be7bca9..1215895 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
@@ -8,10 +8,14 @@
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/json/values_util.h"
+#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/test_timeouts.h"
#include "base/win/windows_version.h"
#include "build/buildflag.h"
+#include "content/browser/file_system_access/file_system_access_directory_handle_impl.h"
+#include "content/browser/file_system_access/file_system_access_manager_impl.h"
+#include "content/browser/file_system_access/fixed_file_system_access_permission_grant.h"
#include "content/browser/web_contents/web_contents_impl.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/common/content_switches.h"
@@ -22,6 +26,7 @@
#include "content/public/test/content_browser_test_utils.h"
#include "content/public/test/file_system_chooser_test_helpers.h"
#include "content/shell/browser/shell.h"
+#include "net/dns/mock_host_resolver.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -1467,4 +1472,122 @@
#endif // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS) &&
// !BUILDFLAG(IS_FUCHSIA)
+using HandleType = FileSystemAccessPermissionContext::HandleType;
+
+class FileSystemAccessObserverCrossOriginTokenBypassTest
+ : public FileSystemAccessObserverBrowserTestBase {
+ public:
+ void SetUpOnMainThread() override {
+ host_resolver()->AddRule("*", "127.0.0.1");
+ FileSystemAccessObserverBrowserTestBase::SetUpOnMainThread();
+ }
+
+ FileSystemAccessManagerImpl* GetManager() {
+ auto* partition =
+ static_cast<StoragePartitionImpl*>(shell()
+ ->web_contents()
+ ->GetBrowserContext()
+ ->GetDefaultStoragePartition());
+ return partition->GetFileSystemAccessManager();
+ }
+
+ GURL GetURL(std::string_view relative_url) {
+ return embedded_test_server()->GetURL(relative_url);
+ }
+
+ GURL GetURL(std::string_view hostname, std::string_view relative_url) {
+ return embedded_test_server()->GetURL(hostname, relative_url);
+ }
+};
+
+// Checks that tokens from an unexpected origin cannot be used to create an
+// observer. https://crbug.com/499917177
+IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest,
+ ObserveRefusesCrossOriginToken) {
+ base::FilePath dir_path;
+ {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ ASSERT_TRUE(base::CreateTemporaryDirInDir(
+ temp_dir_.GetPath(), FILE_PATH_LITERAL("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.
+ GURL url_attacker = GetURL("b.com", "/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);
+
+ // Navigate to get a valid RFH (used for binding contexts).
+ ASSERT_TRUE(NavigateToURL(shell(), url_victim));
+ RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+ ASSERT_TRUE(rfh);
+
+ auto* manager = GetManager();
+ ASSERT_TRUE(manager);
+
+ const storage::FileSystemURL dir_url =
+ manager->CreateFileSystemURLFromPath(PathInfo(dir_path));
+
+ auto read_grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+ FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+ PathInfo(dir_path));
+ auto write_grant = base::MakeRefCounted<FixedFileSystemAccessPermissionGrant>(
+ FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED,
+ PathInfo(dir_path));
+ FileSystemAccessManagerImpl::SharedHandleState handle_state(read_grant,
+ write_grant);
+
+ // Create a directory handle owned by origin A.
+ FileSystemAccessManagerImpl::BindingContext victim_context(
+ key_victim, url_victim, rfh->GetGlobalId());
+ auto dir_handle = std::make_unique<FileSystemAccessDirectoryHandleImpl>(
+ manager, victim_context, dir_url, handle_state);
+
+ // Create a registered transfer token from the directory handle.
+ // Using CreateTransferToken ensures the token is inserted into the
+ // manager's transfer_tokens_ map so that ResolveTransferToken can find it.
+ mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+ manager->CreateTransferToken(*dir_handle,
+ token_remote.InitWithNewPipeAndPassReceiver());
+
+ // Create a context with B's key but A's URL.
+ FileSystemAccessManagerImpl::BindingContext attacker_context(
+ key_attacker, url_victim, rfh->GetGlobalId());
+
+ // Bind an ObserverHost with origin B's context.
+ mojo::Remote<blink::mojom::FileSystemAccessObserverHost> observer_host;
+ manager->watcher_manager().BindObserverHost(
+ attacker_context, observer_host.BindNewPipeAndPassReceiver());
+
+ // --- The exploit: Observe() with A's token from B's host ---
+ base::RunLoop run_loop;
+ blink::mojom::FileSystemAccessStatus observe_status;
+ observer_host->Observe(
+ std::move(token_remote), /*is_recursive=*/true,
+ base::BindLambdaForTesting(
+ [&](blink::mojom::FileSystemAccessErrorPtr result,
+ mojo::PendingReceiver<blink::mojom::FileSystemAccessObserver>
+ receiver) {
+ observe_status = result->status;
+ run_loop.Quit();
+ }));
+ run_loop.Run();
+
+ // THE BUG: Observe() succeeds despite origin mismatch.
+ //
+ // DidResolveTransferTokenToObserve does not call IsValidTransferToken
+ // to check the token origin against the binding context origin.
+ // Compare with DidResolveTransferTokenForFileHandle (manager_impl.cc:1442)
+ // and DidResolveTransferTokenForDirectoryHandle (manager_impl.cc:1467)
+ // which both call IsValidTransferToken and reject cross-origin tokens.
+ ASSERT_EQ(observe_status,
+ blink::mojom::FileSystemAccessStatus::kInvalidArgument);
+}
+
} // namespace content
Original Bug Report
Missing origin check in FileSystemAccessObserverHost::Observe() allows cross-origin file observation
Steps to reproduce the problem
-
Apply the attached patch from the root of the Chromium src tree: git apply fsa_observer_xorigin_bypass.patch
-
Build: autoninja -C out/Default content_browsertests
-
Run: out/Default/content_browsertests
–gtest_filter=’ObserverCrossOriginTokenBypass' -
Observe the output:
- “ObserveAcceptsCrossOriginToken” PASSES with log: “VULNERABILITY CONFIRMED: Observe() accepted a transfer token from https://victim.example on an ObserverHost bound to https://attacker.example”
- “ResolveTransferTokenRejectsCrossOriginToken” PASSES, confirming origin validation works on other code paths and is only missing from Observe().
Problem Description
FileSystemAccessObserverHost::DidResolveTransferTokenToObserve() does not validate the transfer token’s origin against the binding context’s origin before using the token.
A compromised renderer for origin B can call Observe() with a transfer token belonging to origin A, gaining the ability to observe file system changes on directories granted exclusively to origin A. The observation is backed by origin A’s permission grants, meaning handles delivered through change notifications carry origin A’s read/write permissions.
Root Cause: In file_system_access_observer_host.cc, DidResolveTransferTokenToObserve() checks:
- Whether the resolved token is non-null
- Whether the read grant has GRANTED status
But it doesn’t check whether resolved_token->origin() matches binding_context().storage_key.origin().
All other transfer token redemption paths perform this check:
- DidResolveTransferTokenForFileHandle (manager_impl.cc:1442) calls IsValidTransferToken()
- DidResolveTransferTokenForDirectoryHandle (manager_impl.cc:1467) calls IsValidTransferToken()
The IsValidTransferToken() function (manager_impl.cc:302) compares token->origin() against the expected origin from the binding context. This check is simply missing from the Observe() path.
Impact:
- Cross-origin file observation: attacker monitors file changes in directories granted to another origin
- Permission grant leakage: directory handles created from the token carry victim origin’s read/write grants
Summary
Missing origin check in FileSystemAccessObserverHost::Observe() allows cross-origin file observation
Custom Questions
Reporter credit:
Eran Rom of Palo Alto Networks
Additional Data
Category: Security
Chrome Channel: Canary
Regression: N/A \