CVE-2026-84328
Overview
Background
- File System Access API
- a browser feature that lets web origins persist and later re-open sandboxed file handles through
FileSystemAccessManagerImpl. - `StorageKey`
- the per-origin (and partition) key that scopes storage in Chromium so that one site cannot reach another site’s data.
- Storage bucket
- a named quota-managed storage container identified by a bucket ID and owned by exactly one
StorageKey, resolved viaQuotaManager::GetBucketById(). - Transfer token
- a
mojocapability produced by serializing aFileSystemHandlethat a caller can later deserialize to regain access to the referenced file.
Root Cause Analysis
When FileSystemAccessManagerImpl::DeserializeHandle rebuilt a sandboxed FileSystemHandle that referenced a non-default storage bucket, it looked the bucket up by ID through QuotaManager and, in the bucket_callback lambda, applied the returned BucketInfo to the FileSystemURL via url.SetBucket(result->ToBucketLocator()) whenever result.has_value() was true. It never verified that the resolved bucket’s storage_key matched the caller’s StorageKey, so a serialized handle carrying another origin’s bucket ID would be rebound to that foreign bucket. The same missing check existed in storage::FileSystemContext when opening a filesystem from a caller-supplied BucketLocator, whose storage_key field was trusted without cross-checking the authoritative owner from quota management. The invariant violated is that a storage bucket must only ever be accessed by its owning StorageKey.
The fix threads the caller’s expected_storage_key into the callback and drops the token (returns FILE_ERROR_FAILED) unless result->storage_key equals it, closing the authorization gap.
StorageKey, treating the bucket ID (and, in FileSystemContext, the BucketLocator::storage_key) as trustworthy. The fix compares the quota-manager’s authoritative result->storage_key against the caller’s key and rejects mismatches.Attack Path
- Obtain a foreign bucket handle
A malicious page crafts or replays a serialized sandboxed
FileSystemHandlewhose embedded bucket ID belongs to a different origin’sStorageKey. - Deserialize under attacker origin
The attacker calls
DeserializeHandlewith its ownStorageKey, and the pre-fixbucket_callbackresolves the victim bucket by ID and accepts it because it merely exists. - Rebind the URL to the victim bucket
url.SetBucket()applies the foreignBucketLocator, producing a live handle pointing into another origin’s sandboxed storage. - Access cross-origin data
Through the resolved transfer token the attacker reads or writes files in the victim origin’s bucket, bypassing
StorageKeyisolation.
Impact Assessment
BucketLocator referencing the victim’s bucket ID. No memory-safety primitive is involved; the gain is unauthorized cross-StorageKey data access.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fstorage/browser/file_system/file_system_context_unittest.cc |
modified |
Files Changed
content/browser/file_system_access/file_system_access_manager_impl.cccontent/browser/file_system_access/file_system_access_manager_impl_unittest.ccstorage/browser/file_system/file_system_context.ccstorage/browser/file_system/file_system_context_unittest.cc
Audit Directions
- Bucket ownership checksEverywhere a bucket is resolved by ID via
QuotaManager::GetBucketById(), confirm the returnedBucketInfo::storage_keyis compared against the authenticated caller’sStorageKeybefore use. - Trusting `BucketLocator` fieldsFlag any code path that consumes a caller- or serialization-supplied
BucketLocator::storage_keywithout re-deriving the owner from quota management. - Deserialization boundariesReview all handle/token deserialization entry points for authorization (not just existence) checks that re-validate the resource belongs to the deserializing origin.
Patch
From e7ab8065a8763f03f5b8c92d0daff4225111875f Mon Sep 17 00:00:00 2001
From: Ming-Ying Chung <mych@chromium.org>
Date: Wed, 19 Aug 2026 02:03:46 -0700
Subject: [PATCH] [FSA] Check bucket storage key when deserializing handles
When deserializing a sandboxed FileSystemHandle that references a
non-default storage bucket, verify that the bucket returned by
`QuotaManager::GetBucketById()` belongs to the same StorageKey as the
caller. If it does not, drop the token, matching the existing behavior
for buckets that no longer exist.
Bug: 498710886
Change-Id: Icf29635c1ec30dbc25baa8bb19e68b1b43c21ac7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8255670
Reviewed-by: Fergal Daly <fergal@chromium.org>
Commit-Queue: Ming-Ying Chung <mych@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1682137}
---
diff --git a/content/browser/file_system_access/file_system_access_manager_impl.cc b/content/browser/file_system_access/file_system_access_manager_impl.cc
index ad279ee..14e62d7 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl.cc
@@ -1250,16 +1250,18 @@
// Apply bucket information.
auto bucket_callback = base::BindOnce(
[](storage::FileSystemURL url,
+ const blink::StorageKey& expected_storage_key,
base::OnceCallback<void(const storage::FileSystemURL&)> callback,
storage::QuotaErrorOr<storage::BucketInfo> result) {
- if (!result.has_value()) {
+ if (!result.has_value() ||
+ result->storage_key != expected_storage_key) {
// Drop `token`, and directly return.
return;
}
url.SetBucket(result->ToBucketLocator());
std::move(callback).Run(url);
},
- url,
+ url, storage_key,
base::BindOnce(&FileSystemAccessManagerImpl::
DidGetSandboxedBucketForDeserializeHandle,
weak_factory_.GetWeakPtr(), data, std::move(token),
diff --git a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
index 00e479c..78c13353 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
@@ -1036,6 +1036,59 @@
EXPECT_EQ(PermissionStatus::GRANTED, token->GetWriteGrant()->GetStatus());
}
+// Verifies that `DeserializeHandle()` rejects sandboxed file handles associated
+// with a storage bucket owned by a different `blink::StorageKey`.
+//
+// The test serializes a handle pointing to a custom bucket under an alternate
+// storage key, then attempts to deserialize it using the test caller's storage
+// key. The deserialization must drop the transfer token and fail to resolve.
+TEST_F(FileSystemAccessManagerImplTest,
+ DeserializeHandle_SandboxedFile_OtherStorageKeyBucket) {
+ // Create a non-default bucket owned by a different storage key.
+ const blink::StorageKey kOtherStorageKey =
+ blink::StorageKey::CreateFromStringForTesting("https://other.example/");
+ base::test::TestFuture<storage::QuotaErrorOr<storage::BucketInfo>>
+ bucket_future;
+ quota_manager_proxy_->CreateBucketForTesting(
+ kOtherStorageKey, "custom_bucket",
+ base::SequencedTaskRunner::GetCurrentDefault(),
+ bucket_future.GetCallback());
+ ASSERT_OK_AND_ASSIGN(auto other_bucket, bucket_future.Take());
+
+ // Serialize a sandboxed file handle that points at the other key's bucket.
+ // The handle must be constructed with a matching binding context to satisfy
+ // token origin validation during serialization.
+ auto test_file_url = file_system_context_->CreateCrackedFileSystemURL(
+ kOtherStorageKey, storage::kFileSystemTypeTemporary,
+ base::FilePath::FromUTF8Unsafe("test/foo/bar"));
+ test_file_url.SetBucket(other_bucket.ToBucketLocator());
+ const FileSystemAccessManagerImpl::BindingContext other_binding_context = {
+ kOtherStorageKey, GURL("https://other.example/"),
+ GlobalRenderFrameHostId()};
+ FileSystemAccessFileHandleImpl file(manager_.get(), other_binding_context,
+ test_file_url, "bar",
+ {ask_grant_, ask_grant_});
+ mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+ manager_->CreateTransferToken(file,
+ token_remote.InitWithNewPipeAndPassReceiver());
+
+ base::test::TestFuture<std::vector<uint8_t>> serialize_future;
+ manager_->SerializeHandle(
+ std::move(token_remote),
+ serialize_future.GetCallback<const std::vector<uint8_t>&>());
+ std::vector<uint8_t> serialized = serialize_future.Take();
+ EXPECT_FALSE(serialized.empty());
+
+ // Deserializing under `kTestStorageKey`, which does not own the bucket, must
+ // drop the transfer token and fail to resolve.
+ manager_->DeserializeHandle(kTestStorageKey, serialized,
+ token_remote.InitWithNewPipeAndPassReceiver());
+ base::test::TestFuture<FileSystemAccessTransferTokenImpl*> resolve_future;
+ manager_->ResolveTransferToken(std::move(token_remote),
+ resolve_future.GetCallback());
+ EXPECT_FALSE(resolve_future.Get());
+}
+
TEST_F(FileSystemAccessManagerImplTest,
SerializeHandle_SandboxedDirectory_CustomBucket) {
auto test_file_url = file_system_context_->CreateCrackedFileSystemURL(
diff --git a/storage/browser/file_system/file_system_context.cc b/storage/browser/file_system/file_system_context.cc
index 457049d..76dc4c5 100644
--- a/storage/browser/file_system/file_system_context.cc
+++ b/storage/browser/file_system/file_system_context.cc
@@ -423,7 +423,7 @@
OpenFileSystemMode mode,
OpenFileSystemCallback callback,
QuotaErrorOr<BucketInfo> result) {
- if (!result.has_value()) {
+ if (!result.has_value() || result->storage_key != storage_key) {
std::move(callback).Run(FileSystemURL(), std::string(),
base::File::FILE_ERROR_FAILED);
return;
diff --git a/storage/browser/file_system/file_system_context_unittest.cc b/storage/browser/file_system/file_system_context_unittest.cc
index a223db35..39e7402a 100644
--- a/storage/browser/file_system/file_system_context_unittest.cc
+++ b/storage/browser/file_system/file_system_context_unittest.cc
@@ -232,6 +232,49 @@
ASSERT_EQ(last_resolved_url_.value().bucket(), bucket.ToBucketLocator());
}
+// Verifies that `OpenFileSystem()` rejects attempts to open a filesystem when
+// the requested storage bucket belongs to a different `blink::StorageKey`.
+//
+// The test constructs a spoofed `storage::BucketLocator` pointing to a bucket
+// ID owned by another storage key and invokes `OpenFileSystem()`. The operation
+// must validate the bucket's actual owner from quota management and fail with
+// `base::File::FILE_ERROR_FAILED`.
+TEST_F(FileSystemContextTest, OpenFileSystem_OtherStorageKeyBucket) {
+ scoped_refptr<FileSystemContext> file_system_context =
+ CreateFileSystemContextForTest(/*external_mount_points=*/nullptr);
+ base::RunLoop run_loop;
+ base::File::Error open_error = base::File::FILE_OK;
+ const auto open_callback = base::BindLambdaForTesting(
+ [&](const FileSystemURL& root_url, const std::string& name,
+ base::File::Error error) {
+ open_error = error;
+ run_loop.Quit();
+ });
+ const auto storage_key =
+ blink::StorageKey::CreateFromStringForTesting("http://host/test.crswap");
+ const auto other_storage_key =
+ blink::StorageKey::CreateFromStringForTesting("http://other/test.crswap");
+ base::test::TestFuture<storage::QuotaErrorOr<storage::BucketInfo>>
+ bucket_future;
+ proxy()->CreateBucketForTesting(
+ other_storage_key, "custom_bucket",
+ base::SequencedTaskRunner::GetCurrentDefault(),
+ bucket_future.GetCallback());
+ ASSERT_OK_AND_ASSIGN(auto bucket, bucket_future.Take());
+
+ // Construct a bucket locator spoofing ownership under `storage_key` while
+ // referencing the bucket ID registered to `other_storage_key`.
+ storage::BucketLocator spoofed_bucket = bucket.ToBucketLocator();
+ spoofed_bucket.storage_key = storage_key;
+
+ file_system_context->OpenFileSystem(
+ storage_key, spoofed_bucket, kFileSystemTypeTemporary,
+ OpenFileSystemMode::OPEN_FILE_SYSTEM_CREATE_IF_NONEXISTENT,
+ std::move(open_callback));
+ run_loop.Run();
+ EXPECT_EQ(base::File::FILE_ERROR_FAILED, open_error);
+}
+
TEST_F(FileSystemContextTest, CrackFileSystemURL) {
scoped_refptr<ExternalMountPoints> external_mount_points =
ExternalMountPoints::CreateRefCounted();
Regression Test / PoC
diff --git a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
index 00e479c..78c13353 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
@@ -1036,6 +1036,59 @@
EXPECT_EQ(PermissionStatus::GRANTED, token->GetWriteGrant()->GetStatus());
}
+// Verifies that `DeserializeHandle()` rejects sandboxed file handles associated
+// with a storage bucket owned by a different `blink::StorageKey`.
+//
+// The test serializes a handle pointing to a custom bucket under an alternate
+// storage key, then attempts to deserialize it using the test caller's storage
+// key. The deserialization must drop the transfer token and fail to resolve.
+TEST_F(FileSystemAccessManagerImplTest,
+ DeserializeHandle_SandboxedFile_OtherStorageKeyBucket) {
+ // Create a non-default bucket owned by a different storage key.
+ const blink::StorageKey kOtherStorageKey =
+ blink::StorageKey::CreateFromStringForTesting("https://other.example/");
+ base::test::TestFuture<storage::QuotaErrorOr<storage::BucketInfo>>
+ bucket_future;
+ quota_manager_proxy_->CreateBucketForTesting(
+ kOtherStorageKey, "custom_bucket",
+ base::SequencedTaskRunner::GetCurrentDefault(),
+ bucket_future.GetCallback());
+ ASSERT_OK_AND_ASSIGN(auto other_bucket, bucket_future.Take());
+
+ // Serialize a sandboxed file handle that points at the other key's bucket.
+ // The handle must be constructed with a matching binding context to satisfy
+ // token origin validation during serialization.
+ auto test_file_url = file_system_context_->CreateCrackedFileSystemURL(
+ kOtherStorageKey, storage::kFileSystemTypeTemporary,
+ base::FilePath::FromUTF8Unsafe("test/foo/bar"));
+ test_file_url.SetBucket(other_bucket.ToBucketLocator());
+ const FileSystemAccessManagerImpl::BindingContext other_binding_context = {
+ kOtherStorageKey, GURL("https://other.example/"),
+ GlobalRenderFrameHostId()};
+ FileSystemAccessFileHandleImpl file(manager_.get(), other_binding_context,
+ test_file_url, "bar",
+ {ask_grant_, ask_grant_});
+ mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_remote;
+ manager_->CreateTransferToken(file,
+ token_remote.InitWithNewPipeAndPassReceiver());
+
+ base::test::TestFuture<std::vector<uint8_t>> serialize_future;
+ manager_->SerializeHandle(
+ std::move(token_remote),
+ serialize_future.GetCallback<const std::vector<uint8_t>&>());
+ std::vector<uint8_t> serialized = serialize_future.Take();
+ EXPECT_FALSE(serialized.empty());
+
+ // Deserializing under `kTestStorageKey`, which does not own the bucket, must
+ // drop the transfer token and fail to resolve.
+ manager_->DeserializeHandle(kTestStorageKey, serialized,
+ token_remote.InitWithNewPipeAndPassReceiver());
+ base::test::TestFuture<FileSystemAccessTransferTokenImpl*> resolve_future;
+ manager_->ResolveTransferToken(std::move(token_remote),
+ resolve_future.GetCallback());
+ EXPECT_FALSE(resolve_future.Get());
+}
+
TEST_F(FileSystemAccessManagerImplTest,
SerializeHandle_SandboxedDirectory_CustomBucket) {
auto test_file_url = file_system_context_->CreateCrackedFileSystemURL(
diff --git a/storage/browser/file_system/file_system_context_unittest.cc b/storage/browser/file_system/file_system_context_unittest.cc
index a223db35..39e7402a 100644
--- a/storage/browser/file_system/file_system_context_unittest.cc
+++ b/storage/browser/file_system/file_system_context_unittest.cc
@@ -232,6 +232,49 @@
ASSERT_EQ(last_resolved_url_.value().bucket(), bucket.ToBucketLocator());
}
+// Verifies that `OpenFileSystem()` rejects attempts to open a filesystem when
+// the requested storage bucket belongs to a different `blink::StorageKey`.
+//
+// The test constructs a spoofed `storage::BucketLocator` pointing to a bucket
+// ID owned by another storage key and invokes `OpenFileSystem()`. The operation
+// must validate the bucket's actual owner from quota management and fail with
+// `base::File::FILE_ERROR_FAILED`.
+TEST_F(FileSystemContextTest, OpenFileSystem_OtherStorageKeyBucket) {
+ scoped_refptr<FileSystemContext> file_system_context =
+ CreateFileSystemContextForTest(/*external_mount_points=*/nullptr);
+ base::RunLoop run_loop;
+ base::File::Error open_error = base::File::FILE_OK;
+ const auto open_callback = base::BindLambdaForTesting(
+ [&](const FileSystemURL& root_url, const std::string& name,
+ base::File::Error error) {
+ open_error = error;
+ run_loop.Quit();
+ });
+ const auto storage_key =
+ blink::StorageKey::CreateFromStringForTesting("http://host/test.crswap");
+ const auto other_storage_key =
+ blink::StorageKey::CreateFromStringForTesting("http://other/test.crswap");
+ base::test::TestFuture<storage::QuotaErrorOr<storage::BucketInfo>>
+ bucket_future;
+ proxy()->CreateBucketForTesting(
+ other_storage_key, "custom_bucket",
+ base::SequencedTaskRunner::GetCurrentDefault(),
+ bucket_future.GetCallback());
+ ASSERT_OK_AND_ASSIGN(auto bucket, bucket_future.Take());
+
+ // Construct a bucket locator spoofing ownership under `storage_key` while
+ // referencing the bucket ID registered to `other_storage_key`.
+ storage::BucketLocator spoofed_bucket = bucket.ToBucketLocator();
+ spoofed_bucket.storage_key = storage_key;
+
+ file_system_context->OpenFileSystem(
+ storage_key, spoofed_bucket, kFileSystemTypeTemporary,
+ OpenFileSystemMode::OPEN_FILE_SYSTEM_CREATE_IF_NONEXISTENT,
+ std::move(open_callback));
+ run_loop.Run();
+ EXPECT_EQ(base::File::FILE_ERROR_FAILED, open_error);
+}
+
TEST_F(FileSystemContextTest, CrackFileSystemURL) {
scoped_refptr<ExternalMountPoints> external_mount_points =
ExternalMountPoints::CreateRefCounted();