Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in IndexedDB
DescriptionInsufficient validation of untrusted input in IndexedDB
ComponentIndexedDB
Bug ClassLogic Error
Tracker497660733
Fix commit5e6a8e5facd8 (chromium/src) +610/-154
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
TEST_P
content/browser/indexed_db/indexed_db_unittest.cc
modified
if
content/browser/indexed_db/instance/blob_reader.cc
modified

Files Changed

  • components/services/storage/public/mojom/blob_storage_context.mojom
  • content/browser/indexed_db/indexed_db_unittest.cc
  • content/browser/indexed_db/instance/blob_reader.cc
  • content/browser/indexed_db/instance/blob_reader.h
From 5e6a8e5facd805ea407cd80783ce34ab32d2fad0 Mon Sep 17 00:00:00 2001
From: Abhishek Shanthkumar <[email protected]>
Date: Mon, 20 Apr 2026 10:41:20 -0700
Subject: [PATCH] IDB: Handle reported blob size differing from actual size

The renderer provides the size for blobs (including files) in
IndexedDBExternalObject, which is typically stored as-is in the backing
store while the contents of the blob are asynchronously written at
commit time. A compromised or buggy renderer may supply a size that is
smaller than the actual size of the blob, which may cause several
problems including (a) transient under-reporting of quota usage, and (b)
writing more data than clients who are reading the stored blob expect.

The SQLite backing store implicitly handles this because it allocates
the declared size in the blob row and an attempt to write more data than
allocated fails. For LevelDB, blobs are written to a flat file on disk
and currently, the blob remote is transparently passed through to the
blob storage context without the accompanying information of the
expected size. This CL mitigates that by plumbing through the expected
size when writing the blob to a file on disk and then erroring out if
the blob size does not match expected size.

Additionally, WriteBlobToFile now deletes any partially written output
file on error, which would earlier be left on disk.

//third_party/blink/public/mojom/blob/data_element.mojom:DataElementFile
specifies that a blob can contain at most one file element with unknown
size, and it must have offset 0. On the browser process side, the former
invariant was only incidentally enforced, while the latter was not. This
CL also enforces these explicitly in `BlobRegistryImpl`.

Bug: 497660733
Change-Id: I37953ea35e24a0bcf2a0e952e0e97a82a5491c7a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7716739
Reviewed-by: Evan Stade <[email protected]>
Reviewed-by: Alex Gough <[email protected]>
Commit-Queue: Abhishek Shanthkumar <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1617602}
---

diff --git a/components/services/storage/public/mojom/blob_storage_context.mojom b/components/services/storage/public/mojom/blob_storage_context.mojom
index bb575ae..d96341a 100644
--- a/components/services/storage/public/mojom/blob_storage_context.mojom
+++ b/components/services/storage/public/mojom/blob_storage_context.mojom
@@ -79,11 +79,13 @@
   // that contains the file at |path| does not exist, then this function will
   // return kIOError. If a file already exists at |path| then it is
   // overwritten. If |flush_on_write| is true, then Flush will be called on the
-  // new file before it is closed.
+  // new file before it is closed. If the blob's actual size does not match
+  // |expected_size|, kInvalidBlob is returned without writing.
   WriteBlobToFile(pending_remote<blink.mojom.Blob> blob,
                   mojo_base.mojom.FilePath path,
                   bool flush_on_write,
-                  mojo_base.mojom.Time? last_modified)
+                  mojo_base.mojom.Time? last_modified,
+                  uint64 expected_size)
       => (WriteBlobToFileResult result);
 
   // Binds another Mojo connection to this context.
diff --git a/content/browser/indexed_db/indexed_db_unittest.cc b/content/browser/indexed_db/indexed_db_unittest.cc
index 3572251f..b82462d 100644
--- a/content/browser/indexed_db/indexed_db_unittest.cc
+++ b/content/browser/indexed_db/indexed_db_unittest.cc
@@ -2621,4 +2621,84 @@
             next_idle_maintenance_time_after_partial_read);
 }
 
+// Regression test for a compromised renderer forging the declared size of an
+// IndexedDB blob to be smaller than its actual data: crbug.com/497660733.
+TEST_P(IndexedDBTest, BlobWithForgedSize) {
+  const int64_t kTransactionId = 1;
+  const int64_t kObjectStoreId = 10;
+  const char16_t kObjectStoreName[] = u"os";
+  const IndexedDBKey kKey(u"key");
+
+  const std::string kBlobData(10000, 'A');
+  const int64_t kForgedBlobSize = 100;
+
+  blob_storage_context_.SetWriteFilesToDisk(true);
+
+  storage::BucketInfo bucket_info = GetOrCreateBucket(GetTestStorageKey());
+
+  mojo::PendingRemote<storage::mojom::IndexedDBClientStateChecker>
+      checker_remote;
+  BindFactory(std::move(checker_remote),
+              factory_remote_.BindNewPipeAndPassReceiver(), bucket_info);
+
+  MockMojoFactoryClient client;
+  MockMojoDatabaseCallbacks database_callbacks;
+  mojo::AssociatedRemote<blink::mojom::IDBTransaction> transaction_remote;
+  mojo::PendingAssociatedRemote<blink::mojom::IDBDatabase> pending_database;
+
+  // Wait for UpgradeNeeded.
+  base::RunLoop upgrade_loop;
+  EXPECT_CALL(client, MockedUpgradeNeeded)
+      .WillOnce(
+          testing::DoAll(MoveArgPointee<0>(&pending_database),
+                         ::base::test::RunClosure(upgrade_loop.QuitClosure())));
+  factory_remote_->Open(client.CreateInterfacePtrAndBind(),
+                        database_callbacks.CreateInterfacePtrAndBind(),
+                        kDatabaseName, /*version=*/1,
+                        transaction_remote.BindNewEndpointAndPassReceiver(),
+                        kTransactionId, /*priority=*/0);
+  upgrade_loop.Run();
+
+  mojo::AssociatedRemote<blink::mojom::IDBDatabase> database(
+      std::move(pending_database));
+  ASSERT_TRUE(database.is_bound());
+
+  transaction_remote->CreateObjectStore(kObjectStoreId, kObjectStoreName,
+                                        blink::IndexedDBKeyPath(), false);
+
+  // Create a FakeBlob with a large body but declare a small (forged) size.
+  auto fake_blob = std::make_unique<storage::FakeBlob>("test-uuid");
+  fake_blob->set_body(kBlobData);
+
+  std::vector<blink::mojom::IDBExternalObjectPtr> external_objects;
+  external_objects.push_back(blink::mojom::IDBExternalObject::NewBlobOrFile(
+      blink::mojom::IDBBlobInfo::New(fake_blob->Clone(), u"text/plain",
+                                     kForgedBlobSize,
+                                     /*file=*/nullptr)));
+
+  auto new_value = blink::mojom::IDBValue::New();
+  new_value->bits = mojo_base::BigBuffer(base::as_byte_span("value"));
+  new_value->external_objects = std::move(external_objects);
+
+  transaction_remote->Put(kObjectStoreId, std::move(new_value), kKey.Clone(),
+                          blink::mojom::IDBPutMode::AddOnly,
+                          std::vector<IndexedDBIndexKeys>(), base::DoNothing());
+  transaction_remote->Commit(0);
+
+  // The blob write should fail because the actual blob size doesn't match the
+  // declared size, aborting the transaction.
+  base::RunLoop error_loop;
+  base::RepeatingClosure quit_closure =
+      base::BarrierClosure(2, error_loop.QuitClosure());
+
+  EXPECT_CALL(database_callbacks,
+              Abort(kTransactionId, blink::mojom::IDBException::kDataError, _))
+      .WillOnce(RunClosure(quit_closure));
+
+  EXPECT_CALL(client, Error(blink::mojom::IDBException::kAbortError, _))
+      .WillOnce(RunClosure(std::move(quit_closure)));
+
+  error_loop.Run();
+}
+
 }  // namespace content::indexed_db
diff --git a/content/browser/indexed_db/instance/blob_reader.cc b/content/browser/indexed_db/instance/blob_reader.cc
index c6a5e69..0fc7df6 100644
--- a/content/browser/indexed_db/instance/blob_reader.cc
+++ b/content/browser/indexed_db/instance/blob_reader.cc
@@ -13,6 +13,7 @@
 #include "base/functional/bind.h"
 #include "base/functional/callback.h"
 #include "base/functional/callback_helpers.h"
+#include "base/numerics/clamped_math.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/uuid.h"
 #include "content/browser/indexed_db/file_stream_reader_to_data_pipe.h"
@@ -30,12 +31,16 @@
   data_pipe_getter_receivers_.Add(this, std::move(receiver));
 }
 
+uint64_t BlobReader::ClampReadLength(uint64_t offset, uint64_t length) const {
+  return base::ClampMin(length, base::ClampSub(blob_length_, offset));
+}
+
 void BlobReader::ReadRange(
     uint64_t offset,
     uint64_t length,
     mojo::ScopedDataPipeProducerHandle handle,
     mojo::PendingRemote<blink::mojom::BlobReaderClient> pending_client) {
-  uint64_t read_length = std::min(blob_length_, length);
+  uint64_t read_length = ClampReadLength(offset, length);
   mojo::Remote<blink::mojom::BlobReaderClient> client;
   if (pending_client) {
     client.Bind(std::move(pending_client));
@@ -113,7 +118,7 @@
     mojo::ScopedDataPipeProducerHandle pipe,
     storage::mojom::BlobDataItemReader::ReadCallback callback) {
   OpenFileAndReadIntoPipe(
-      file_path_, offset, length, std::move(pipe),
+      file_path_, offset, ClampReadLength(offset, length), std::move(pipe),
       base::BindOnce(
           [](base::OnceCallback<void(net::Error)> on_read_complete,
              storage::mojom::BlobDataItemReader::ReadCallback callback,
diff --git a/content/browser/indexed_db/instance/blob_reader.h b/content/browser/indexed_db/instance/blob_reader.h
index 5c46500..e7c186b 100644
--- a/content/browser/indexed_db/instance/blob_reader.h
+++ b/content/browser/indexed_db/instance/blob_reader.h
@@ -77,6 +77,10 @@
   void BindRegistryBlob(storage::mojom::BlobStorageContext& blob_registry);
   void OnMojoDisconnect();
 
+  // Clamps `length` to fit within the blob given the starting position
+  // `offset`.
+  uint64_t ClampReadLength(uint64_t offset, uint64_t length) const;
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/indexed_db/indexed_db_unittest.cc b/content/browser/indexed_db/indexed_db_unittest.cc
index 3572251f..b82462d 100644
--- a/content/browser/indexed_db/indexed_db_unittest.cc
+++ b/content/browser/indexed_db/indexed_db_unittest.cc
@@ -2621,4 +2621,84 @@
             next_idle_maintenance_time_after_partial_read);
 }
 
+// Regression test for a compromised renderer forging the declared size of an
+// IndexedDB blob to be smaller than its actual data: crbug.com/497660733.
+TEST_P(IndexedDBTest, BlobWithForgedSize) {
+  const int64_t kTransactionId = 1;
+  const int64_t kObjectStoreId = 10;
+  const char16_t kObjectStoreName[] = u"os";
+  const IndexedDBKey kKey(u"key");
+
+  const std::string kBlobData(10000, 'A');
+  const int64_t kForgedBlobSize = 100;
+
+  blob_storage_context_.SetWriteFilesToDisk(true);
+
+  storage::BucketInfo bucket_info = GetOrCreateBucket(GetTestStorageKey());
+
+  mojo::PendingRemote<storage::mojom::IndexedDBClientStateChecker>
+      checker_remote;
+  BindFactory(std::move(checker_remote),
+              factory_remote_.BindNewPipeAndPassReceiver(), bucket_info);
+
+  MockMojoFactoryClient client;
+  MockMojoDatabaseCallbacks database_callbacks;
+  mojo::AssociatedRemote<blink::mojom::IDBTransaction> transaction_remote;
+  mojo::PendingAssociatedRemote<blink::mojom::IDBDatabase> pending_database;
+
+  // Wait for UpgradeNeeded.
+  base::RunLoop upgrade_loop;
+  EXPECT_CALL(client, MockedUpgradeNeeded)
+      .WillOnce(
+          testing::DoAll(MoveArgPointee<0>(&pending_database),
+                         ::base::test::RunClosure(upgrade_loop.QuitClosure())));
+  factory_remote_->Open(client.CreateInterfacePtrAndBind(),
+                        database_callbacks.CreateInterfacePtrAndBind(),
+                        kDatabaseName, /*version=*/1,
+                        transaction_remote.BindNewEndpointAndPassReceiver(),
+                        kTransactionId, /*priority=*/0);
+  upgrade_loop.Run();
+
+  mojo::AssociatedRemote<blink::mojom::IDBDatabase> database(
+      std::move(pending_database));
+  ASSERT_TRUE(database.is_bound());
+
+  transaction_remote->CreateObjectStore(kObjectStoreId, kObjectStoreName,
+                                        blink::IndexedDBKeyPath(), false);
+
+  // Create a FakeBlob with a large body but declare a small (forged) size.
+  auto fake_blob = std::make_unique<storage::FakeBlob>("test-uuid");
+  fake_blob->set_body(kBlobData);
+
+  std::vector<blink::mojom::IDBExternalObjectPtr> external_objects;
+  external_objects.push_back(blink::mojom::IDBExternalObject::NewBlobOrFile(
+      blink::mojom::IDBBlobInfo::New(fake_blob->Clone(), u"text/plain",
+                                     kForgedBlobSize,
+                                     /*file=*/nullptr)));
+
+  auto new_value = blink::mojom::IDBValue::New();
+  new_value->bits = mojo_base::BigBuffer(base::as_byte_span("value"));
+  new_value->external_objects = std::move(external_objects);
+
+  transaction_remote->Put(kObjectStoreId, std::move(new_value), kKey.Clone(),
+                          blink::mojom::IDBPutMode::AddOnly,
+                          std::vector<IndexedDBIndexKeys>(), base::DoNothing());
+  transaction_remote->Commit(0);
+
+  // The blob write should fail because the actual blob size doesn't match the
+  // declared size, aborting the transaction.
+  base::RunLoop error_loop;
+  base::RepeatingClosure quit_closure =
+      base::BarrierClosure(2, error_loop.QuitClosure());
+
+  EXPECT_CALL(database_callbacks,
+              Abort(kTransactionId, blink::mojom::IDBException::kDataError, _))
+      .WillOnce(RunClosure(quit_closure));
+
+  EXPECT_CALL(client, Error(blink::mojom::IDBException::kAbortError, _))
+      .WillOnce(RunClosure(std::move(quit_closure)));
+
+  error_loop.Run();
+}
+
 }  // namespace content::indexed_db
diff --git a/content/browser/indexed_db/instance/sqlite/database_connection_unittest.cc b/content/browser/indexed_db/instance/sqlite/database_connection_unittest.cc
index 8c55e42..a043850d 100644
--- a/content/browser/indexed_db/instance/sqlite/database_connection_unittest.cc
+++ b/content/browser/indexed_db/instance/sqlite/database_connection_unittest.cc
@@ -73,6 +73,7 @@
                        const base::FilePath& path,
                        bool flush_on_write,
                        std::optional<base::Time> last_modified,
+                       uint64_t expected_size,
                        WriteBlobToFileCallback callback) override {
     NOTREACHED();
   }
diff --git a/storage/browser/blob/blob_registry_impl_unittest.cc b/storage/browser/blob/blob_registry_impl_unittest.cc
index 3078c74..208fd1e 100644
--- a/storage/browser/blob/blob_registry_impl_unittest.cc
+++ b/storage/browser/blob/blob_registry_impl_unittest.cc
@@ -594,6 +594,69 @@
   EXPECT_EQ(0u, BlobsUnderConstruction());
 }
 
+TEST_F(BlobRegistryImplTest, Register_SingleUnknownSizeFile) {
+  delegate_ptr_->can_read_file_result = true;
+
+  const std::string kId = "id";
+  const base::FilePath path(FILE_PATH_LITERAL("foobar"));
+
+  std::vector<blink::mojom::DataElementPtr> elements;
+  elements.push_back(
+      blink::mojom::DataElement::NewFile(blink::mojom::DataElementFile::New(
+          path, 0, std::numeric_limits<uint64_t>::max(), std::nullopt)));
+
+  mojo::PendingRemote<blink::mojom::Blob> blob;
+  EXPECT_TRUE(registry_->Register(blob.InitWithNewPipeAndPassReceiver(), kId,
+                                  "", "", std::move(elements)));
+  EXPECT_TRUE(bad_messages_.empty());
+
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  WaitForBlobCompletion(handle.get());
+
+  EXPECT_FALSE(handle->IsBroken());
+  EXPECT_EQ(BlobStatus::DONE, handle->GetBlobStatus());
+  EXPECT_EQ(std::numeric_limits<uint64_t>::max(), handle->size());
+}
+
+TEST_F(BlobRegistryImplTest, Register_UnknownSizeFileWithOtherElements) {
+  delegate_ptr_->can_read_file_result = true;
+
+  const std::string kId = "id";
+  const base::FilePath path(FILE_PATH_LITERAL("foobar"));
+  const std::string kData = "hello world";
+
+  std::vector<blink::mojom::DataElementPtr> elements;
+  elements.push_back(
+      blink::mojom::DataElement::NewFile(blink::mojom::DataElementFile::New(
+          path, 0, std::numeric_limits<uint64_t>::max(), std::nullopt)));
+  elements.push_back(
+      blink::mojom::DataElement::NewBytes(blink::mojom::DataElementBytes::New(
+          kData.size(), std::vector<uint8_t>(kData.begin(), kData.end()),
+          CreateBytesProvider(kData))));
+
+  mojo::PendingRemote<blink::mojom::Blob> blob;
+  EXPECT_FALSE(registry_->Register(blob.InitWithNewPipeAndPassReceiver(), kId,
+                                   "", "", std::move(elements)));
+  EXPECT_EQ(1u, bad_messages_.size());
+}
+
+TEST_F(BlobRegistryImplTest, Register_UnknownSizeFileWithNonZeroOffset) {
+  delegate_ptr_->can_read_file_result = true;
+
+  const std::string kId = "id";
+  const base::FilePath path(FILE_PATH_LITERAL("foobar"));
+
+  std::vector<blink::mojom::DataElementPtr> elements;
+  elements.push_back(
+      blink::mojom::DataElement::NewFile(blink::mojom::DataElementFile::New(
+          path, 10, std::numeric_limits<uint64_t>::max(), std::nullopt)));
+
+  mojo::PendingRemote<blink::mojom::Blob> blob;
+  EXPECT_FALSE(registry_->Register(blob.InitWithNewPipeAndPassReceiver(), kId,
+                                   "", "", std::move(elements)));
+  EXPECT_EQ(1u, bad_messages_.size());
+}
+
 TEST_F(BlobRegistryImplTest, Register_BytesInvalidEmbeddedData) {
   const std::string kId = "id";
 
@@ -849,10 +912,11 @@
   for (const auto& item : snapshot->items()) {
     EXPECT_EQ(BlobDataItem::Type::kFile, item->type());
     EXPECT_EQ(0u, item->offset());
-    if (remaining_size > kTestBlobStorageMaxFileSizeBytes)
+    if (remaining_size > kTestBlobStorageMaxFileSizeBytes) {
       EXPECT_EQ(kTestBlobStorageMaxFileSizeBytes, item->length());
-    else
+    } else {
       EXPECT_EQ(remaining_size, item->length());
+    }
     remaining_size -= item->length();
   }
   EXPECT_EQ(0u, remaining_size);
diff --git a/storage/browser/blob/blob_storage_context_mojo_unittest.cc b/storage/browser/blob/blob_storage_context_mojo_unittest.cc
index 9339896..0e46c8e 100644
--- a/storage/browser/blob/blob_storage_context_mojo_unittest.cc
+++ b/storage/browser/blob/blob_storage_context_mojo_unittest.cc
@@ -159,7 +159,7 @@
   EXPECT_EQ(std::string(kData), received);
 }
 
-TEST_F(BlobStorageContextMojoTest, SaveBlobToFile) {
+TEST_F(BlobStorageContextMojoTest, WriteBlobToFile) {
   SetUpOnDiskContext();
   const std::string kData = "Hello There!";
   mojo::Remote<mojom::BlobStorageContext> context = CreateContextConnection();
@@ -175,7 +175,7 @@
   base::RunLoop loop;
   base::FilePath file_path = temp_dir_.GetPath().AppendASCII("TestFile.txt");
   context->WriteBlobToFile(
-      blob.Unbind(), file_path, true, last_modified,
+      blob.Unbind(), file_path, true, last_modified, kData.size(),
       base::BindLambdaForTesting([&](mojom::WriteBlobToFileResult result) {
         EXPECT_EQ(result, mojom::WriteBlobToFileResult::kSuccess);
         loop.Quit();
@@ -199,7 +199,7 @@
   ASSERT_TRUE(temp_dir_.Delete());
 }
 
-TEST_F(BlobStorageContextMojoTest, SaveBlobToFileNoDate) {
+TEST_F(BlobStorageContextMojoTest, WriteBlobToFileNoDate) {
   SetUpOnDiskContext();
   const std::string kData = "Hello There!";
   mojo::Remote<mojom::BlobStorageContext> context = CreateContextConnection();
@@ -211,7 +211,7 @@
   base::RunLoop loop;
   base::FilePath file_path = temp_dir_.GetPath().AppendASCII("TestFile.txt");
   context->WriteBlobToFile(
-      blob.Unbind(), file_path, true, std::nullopt,
+      blob.Unbind(), file_path, true, std::nullopt, kData.size(),
       base::BindLambdaForTesting([&](mojom::WriteBlobToFileResult result) {
         EXPECT_EQ(result, mojom::WriteBlobToFileResult::kSuccess);
         loop.Quit();
@@ -227,7 +227,7 @@
   ASSERT_TRUE(temp_dir_.Delete());
 }
 
-TEST_F(BlobStorageContextMojoTest, SaveEmptyBlobToFile) {
+TEST_F(BlobStorageContextMojoTest, WriteEmptyBlobToFile) {
   SetUpOnDiskContext();
   mojo::Remote<mojom::BlobStorageContext> context = CreateContextConnection();
 
@@ -242,7 +242,7 @@
   base::RunLoop loop;
   base::FilePath file_path = temp_dir_.GetPath().AppendASCII("TestFile.txt");
   context->WriteBlobToFile(
-      blob.Unbind(), file_path, true, last_modified,
+      blob.Unbind(), file_path, true, last_modified, 0,
       base::BindLambdaForTesting([&](mojom::WriteBlobToFileResult result) {
         EXPECT_EQ(result, mojom::WriteBlobToFileResult::kSuccess);
         loop.Quit();
@@ -294,7 +294,7 @@
   base::FilePath file_path =
       temp_dir_.GetPath().AppendASCII("DestinationFile.txt");
   context->WriteBlobToFile(
-      std::move(blob), file_path, true, modification_time,
+      std::move(blob), file_path, true, modification_time, kData.size(),
       base::BindLambdaForTesting([&](mojom::WriteBlobToFileResult result) {
         EXPECT_EQ(result, mojom::WriteBlobToFileResult::kSuccess);
         loop.Quit();
@@ -347,7 +347,7 @@
   base::FilePath file_path =
       temp_dir_.GetPath().AppendASCII("DestinationFile.txt");
   context->WriteBlobToFile(
-      blob.Unbind(), file_path, true, modification_time,
+      blob.Unbind(), file_path, true, modification_time, kSize,
       base::BindLambdaForTesting([&](mojom::WriteBlobToFileResult result) {
         EXPECT_EQ(result, mojom::WriteBlobToFileResult::kSuccess);
         loop.Quit();
@@ -371,6 +371,49 @@
   ASSERT_TRUE(temp_dir_.Delete());
 }
 
+TEST_F(BlobStorageContextMojoTest, FileCopyOptimizationZeroOffsetSlice) {
+  SetUpOnDiskContext();
+  static const std::string kData = "Hello There!";
+  static const int64_t kSize = kData.size() - 2;
+
+  base::FilePath copy_from_file =
+      temp_dir_.GetPath().AppendASCII("SourceFile.txt");
+
+  base::Time modification_time =
+      TruncateToSeconds(base::Time::Now() - base::Days(1));
+  CreateFile(copy_from_file, kData, modification_time);
+
+  std::unique_ptr<BlobDataBuilder> builder =
+      std::make_unique<BlobDataBuilder>("1234");
+  builder->AppendFile(copy_from_file, 0, kSize, modification_time);
+  std::unique_ptr<BlobDataHandle> blob_handle =
+      context_->AddFinishedBlob(std::move(builder));
+
+  mojo::Remote<blink::mojom::Blob> blob;
+  BlobImpl::Create(std::move(blob_handle), blob.BindNewPipeAndPassReceiver());
+
+  mojo::Remote<mojom::BlobStorageContext> context = CreateContextConnection();
+
+  base::RunLoop loop;
+  base::FilePath file_path =
+      temp_dir_.GetPath().AppendASCII("DestinationFile.txt");
+  context->WriteBlobToFile(
+      blob.Unbind(), file_path, true, modification_time, kSize,
+      base::BindLambdaForTesting([&](mojom::WriteBlobToFileResult result) {
+        EXPECT_EQ(result, mojom::WriteBlobToFileResult::kSuccess);
+        loop.Quit();
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

HTTP Request Smuggling via IndexedDB Blob Size Forgery and DataPipe Underflow

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A compromised renderer can forge the size of an IndexedDB blob to be much smaller than its physical size. When fetched, an integer underflow in DataPipeElementReader bypasses EOF checks, streaming the entire file. This sends an HTTP request with a small Content-Length but a large body, enabling persistent HTTP Request Smuggling.

Affected files:

  • content/browser/indexed_db/instance/blob_reader.cc
  • content/browser/indexed_db/instance/transaction.cc
  • content/browser/indexed_db/instance/leveldb/backing_store.cc
  • services/network/data_pipe_element_reader.cc

Estimated timestamp from git blame: 2025-11-11

Summary

Two related bugs allow a compromised renderer to plant a malicious IndexedDB blob that, when later fetched by an uncompromised renderer, results in persistent HTTP Request Smuggling.

First, IndexedDB does not sufficiently validate the size of a blob against its actual physical size during a put operation. Second, DataPipeElementReader in the network service fails to clamp its buffer read sizes, leading to an integer underflow when a data pipe provides more data than expected. This causes the browser to stream the entire physical file into the HTTP request body while maintaining a small Content-Length header.

Technical Details

  1. Forging Blob Size: In content/browser/indexed_db/instance/transaction.cc, Transaction::CreateExternalObjects only checks that info->size >= 0. A compromised renderer can provide a valid Mojo handle to a large blob (e.g., 1MB) but forge the IDBBlobInfo.size to be a small value (e.g., 100 bytes).
  2. Unbounded Disk Write: BackingStore::Transaction::WriteNewBlobs streams the full 1MB blob to disk, ignoring the forged size. The metadata stored in LevelDB retains the forged size (100).
  3. Blob Sourced in fetch: When an uncompromised renderer later retrieves this blob and passes it to fetch(), the browser prepares the upload.
  4. Flawed BlobReader Streaming: BlobReader::Read (content/browser/indexed_db/instance/blob_reader.cc) reports blob_length_ (100) to the network service callback, but subsequently invokes its internal Read method with std::numeric_limits<uint64_t>::max() as the length. This pumps the entire 1MB file into the Mojo data pipe.
  5. DataPipeElementReader Integer Underflow: The network service sets Content-Length: 100 and uses DataPipeElementReader (services/network/data_pipe_element_reader.cc) to consume the data pipe. However, DataPipeElementReader::ReadInternal does not clamp the requested read amount to BytesRemaining(). It reads up to buf_length (e.g., 4096 bytes or more) directly from the pipe.
  6. EOF Bypass: Because 4096 bytes are read, bytes_read_ becomes 4096. On the next read, BytesRemaining() calculates size_ - bytes_read_ (100 - 4096). Since these are uint64_t, this integer underflows to a massive positive number (0xfffffffffffff064).
  7. Smuggling Execution: With the EOF check (BytesRemaining() == 0) permanently bypassed, the entire 1MB payload is written to the network socket. The remote HTTP server reads only the first 100 bytes for the current request (due to Content-Length: 100) and treats the remaining 999,900 bytes as a new, attacker-controlled HTTP request on the keep-alive connection.

Potential Reproduction Steps

Note: These are suggested steps based on code analysis, as we currently lack the ability to run an automated PoC.

  1. From a compromised renderer, create a 1MB payload via blink.mojom.BlobRegistry.
  2. Initiate an IndexedDB put transaction. Pass an IDBExternalObject referencing the 1MB blob but with IDBBlobInfo.size explicitly set to 100.
  3. From an uncompromised renderer (e.g., after a browser restart or a different page load on the same origin), retrieve the blob from IndexedDB.
  4. Execute fetch('https://target-server.com/', {method: 'POST', body: retrieved_blob, keepalive: true}).
  5. Observe that the browser sends 1MB of body data despite the Content-Length: 100 header, leading to Request Smuggling on the target server.

Suggested Fix

  1. In services/network/data_pipe_element_reader.cc: Clamp the read amount in ReadInternal to prevent underflows.
size_t num_bytes = base::checked_cast<size_t>(
    std::min(BytesRemaining(), static_cast<uint64_t>(buf_length)));
  1. In content/browser/indexed_db/instance/blob_reader.cc: Only stream up to blob_length_ into the pipe instead of std::numeric_limits<uint64_t>::max().
void BlobReader::Read(mojo::ScopedDataPipeProducerHandle pipe,
                      network::mojom::DataPipeGetter::ReadCallback callback) {
  std::move(callback).Run(net::OK, blob_length_);
  Read(0, blob_length_, std::move(pipe), base::DoNothing());
}

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from 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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker
Links in the report