Medium chrome Logic Error 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in FileSystem
DescriptionMissing authorization in FileSystem
ComponentFileSystem
Bug ClassLogic Error
Tracker498710886
Fix commite7ab8065a876 (chromium/src) +101/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-01

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 via QuotaManager::GetBucketById().
Transfer token
a mojo capability produced by serializing a FileSystemHandle that 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.

Key insight
The core mistake was authenticating that a bucket exists but not authorizing that it belongs to the requesting 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

  1. Obtain a foreign bucket handle A malicious page crafts or replays a serialized sandboxed FileSystemHandle whose embedded bucket ID belongs to a different origin’s StorageKey.
  2. Deserialize under attacker origin The attacker calls DeserializeHandle with its own StorageKey, and the pre-fix bucket_callback resolves the victim bucket by ID and accepts it because it merely exists.
  3. Rebind the URL to the victim bucket url.SetBucket() applies the foreign BucketLocator, producing a live handle pointing into another origin’s sandboxed storage.
  4. Access cross-origin data Through the resolved transfer token the attacker reads or writes files in the victim origin’s bucket, bypassing StorageKey isolation.

Impact Assessment

An attacker-controlled web origin gains read/write access to another origin’s sandboxed File System Access storage, breaking same-origin storage isolation from within the renderer-facing browser-process API. The vulnerability is a missing authorization (medium severity per the metadata) requiring the attacker to supply a serialized handle or BucketLocator referencing the victim’s bucket ID. No memory-safety primitive is involved; the gain is unauthorized cross-StorageKey data access.

Changed Functions

FunctionChangeNotes
TEST_F
storage/browser/file_system/file_system_context_unittest.cc
modified

Files Changed

  • content/browser/file_system_access/file_system_access_manager_impl.cc
  • content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
  • storage/browser/file_system/file_system_context.cc
  • storage/browser/file_system/file_system_context_unittest.cc

Audit Directions

  • Bucket ownership checks
    Everywhere a bucket is resolved by ID via QuotaManager::GetBucketById(), confirm the returned BucketInfo::storage_key is compared against the authenticated caller’s StorageKey before use.
  • Trusting `BucketLocator` fields
    Flag any code path that consumes a caller- or serialization-supplied BucketLocator::storage_key without re-deriving the owner from quota management.
  • Deserialization boundaries
    Review all handle/token deserialization entry points for authorization (not just existence) checks that re-validate the resource belongs to the deserializing origin.
From e7ab8065a8763f03f5b8c92d0daff4225111875f Mon Sep 17 00:00:00 2001
From: Ming-Ying Chung <[email protected]>
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 <[email protected]>
Commit-Queue: Ming-Ying Chung <[email protected]>
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();
Loading diff…

Regression Test / PoC

shipped with the fix
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();
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.