CVE-2026-87515
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
GetAccessCallbackcontent/browser/blob_storage/file_backed_blob_factory_base.cc |
modified | |
ifcontent/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc |
modified |
Files Changed
content/browser/blob_storage/file_backed_blob_factory_base.cccontent/browser/blob_storage/file_backed_blob_factory_frame_impl.cccontent/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cccontent/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
Patch
From 8f922b345d4e97005fb8825aaf0f9af76ebc81c3 Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <elkurin@google.com>
Date: Thu, 06 Aug 2026 00:18:34 -0700
Subject: [PATCH] Deny file-backed blob access when destination URL is unknown
GetAccessCallback() in FileBackedBlobFactoryBase returned a null
callback when the destination URL was invalid, even though a
ScopedFileAccessDelegate was installed. Downstream consumers treat a
null callback as "no destination known" and route to the default access
path, which is not the intended behaviour when a delegate is performing
per-destination checks.
Separate the two early-return conditions: keep returning a null callback
when no delegate is installed, but return a callback that denies access
when a delegate is installed and the destination URL is invalid (e.g.
opaque-origin workers, fenced frames). Update the comment in
FileBackedBlobFactoryFrameImpl::GetCurrentUrl() accordingly.
TAG=agy
CONV=c4887307-6afe-4a3c-a864-ba0143515e6a
Bug: 497093426
Change-Id: I5ee02e8788e35e4619b94c16b64a6d923a3dacd7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8182087
Commit-Queue: Eriko Kurimoto <elkurin@chromium.org>
Reviewed-by: Mingyu Lei <leimy@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1674764}
---
diff --git a/content/browser/blob_storage/file_backed_blob_factory_base.cc b/content/browser/blob_storage/file_backed_blob_factory_base.cc
index 0065e6f..84f37d04 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_base.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_base.cc
@@ -4,10 +4,12 @@
#include "content/browser/blob_storage/file_backed_blob_factory_base.h"
+#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/process/process_handle.h"
#include "base/task/bind_post_task.h"
#include "base/task/sequenced_task_runner.h"
+#include "components/file_access/scoped_file_access.h"
#include "content/browser/blob_storage/chrome_blob_storage_context.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
#include "content/public/browser/browser_context.h"
@@ -26,11 +28,22 @@
file_access::ScopedFileAccessDelegate::RequestFilesAccessIOCallback
GetAccessCallback(const GURL& url_for_file_access_checks) {
- if (!file_access::ScopedFileAccessDelegate::HasInstance() ||
- !url_for_file_access_checks.is_valid()) {
+ if (!file_access::ScopedFileAccessDelegate::HasInstance()) {
return base::NullCallback();
}
+ // When a delegate is installed, per-destination access checks are required.
+ // If the destination URL cannot be determined or is invalid, explicitly
+ // deny access rather than returning a null callback which would fall back
+ // to default access handling.
+ if (!url_for_file_access_checks.is_valid()) {
+ return base::BindRepeating(
+ [](const std::vector<base::FilePath>&,
+ base::OnceCallback<void(file_access::ScopedFileAccess)> callback) {
+ std::move(callback).Run(file_access::ScopedFileAccess::Denied());
+ });
+ }
+
file_access::ScopedFileAccessDelegate* file_access =
file_access::ScopedFileAccessDelegate::Get();
CHECK(file_access);
diff --git a/content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc b/content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc
index 6991a4f..e9f4afe 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc
@@ -28,8 +28,8 @@
GURL FileBackedBlobFactoryFrameImpl::GetCurrentUrl() {
// TODO(b/276857839): handling of fenced frames is still in discussion. For
- // now we use an invalid GURL as destination URL. This will allow access to
- // unrestricted files but block access to restricted ones.
+ // now we use an invalid GURL as destination URL, which causes file access to
+ // be denied when a ScopedFileAccessDelegate is installed.
if (render_frame_host().IsNestedWithinFencedFrame()) {
return GURL();
}
diff --git a/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc b/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
index fdeeadba..f682cc97 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
@@ -5,6 +5,8 @@
#include "content/browser/blob_storage/file_backed_blob_factory_frame_impl.h"
#include "base/test/task_environment.h"
+#include "base/test/test_future.h"
+#include "components/file_access/scoped_file_access.h"
#include "components/file_access/test/mock_scoped_file_access_delegate.h"
#include "content/browser/blob_storage/chrome_blob_storage_context.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -16,6 +18,8 @@
#include "mojo/public/cpp/system/functions.h"
#include "storage/browser/blob/blob_data_builder.h"
#include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
+#include "storage/browser/blob/blob_data_snapshot.h"
#include "storage/browser/blob/blob_storage_constants.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -302,4 +306,66 @@
EXPECT_NE(GURL(kMainFrameUrl), captured_destination);
}
+TEST_F(FileBackedBlobFactoryFrameImplTest,
+ Register_FencedFrameWithScopedFileAccessDelegate) {
+ main_test_rfh()->InitializeRenderFrameIfNeeded();
+ TestRenderFrameHost* fenced_rfh = main_test_rfh()->AppendFencedFrame();
+ EXPECT_TRUE(fenced_rfh);
+ if (!fenced_rfh) {
+ return;
+ }
+ fenced_rfh->SetLastCommittedUrl(GURL(kSubframeUrl));
+
+ mojo::AssociatedRemote<blink::mojom::FileBackedBlobFactory> fenced_factory;
+ FileBackedBlobFactoryFrameImpl::CreateForCurrentDocument(
+ fenced_rfh, fenced_factory.BindNewEndpointAndPassDedicatedReceiver());
+
+ file_access::MockScopedFileAccessDelegate scoped_file_access_delegate;
+ EXPECT_CALL(scoped_file_access_delegate, CreateFileAccessCallback).Times(0);
+
+ const base::FilePath path = base::FilePath(TEST_PATH("/dir/testfile"));
+ ChildProcessSecurityPolicyImpl::GetInstance()->GrantReadFile(
+ fenced_rfh->GetProcess()->GetID(), path);
+ EXPECT_TRUE(ChildProcessSecurityPolicyImpl::GetInstance()->CanReadFile(
+ fenced_rfh->GetProcess()->GetID(), path));
+
+ auto element =
+ blink::mojom::DataElementFile::New(path, kOffset, kSize, std::nullopt);
+
+ mojo::Remote<blink::mojom::Blob> blob;
+ fenced_factory->RegisterBlob(blob.BindNewPipeAndPassReceiver(), kId, kType,
+ std::move(element));
+ fenced_factory.FlushForTesting();
+ blob.FlushForTesting();
+
+ EXPECT_TRUE(bad_messages_.empty());
+
+ auto* blob_storage_context =
+ ChromeBlobStorageContext::GetFor(main_test_rfh()->GetBrowserContext())
+ ->context();
+
+ std::unique_ptr<storage::BlobDataHandle> handle =
+ blob_storage_context->GetBlobDataFromUUID(kId);
+ WaitForBlobCompletion(handle.get());
+
+ EXPECT_FALSE(handle->IsBroken());
+ EXPECT_EQ(storage::BlobStatus::DONE, handle->GetBlobStatus());
+
+ std::unique_ptr<storage::BlobDataSnapshot> snapshot =
+ handle->CreateSnapshot();
+ EXPECT_EQ(1u, snapshot->items().size());
+ if (snapshot->items().size() != 1u) {
+ return;
+ }
+ auto file_access = snapshot->items()[0]->file_access();
+ EXPECT_FALSE(file_access.is_null());
+ if (file_access.is_null()) {
+ return;
+ }
+
+ base::test::TestFuture<file_access::ScopedFileAccess> future;
+ file_access.Run({path}, future.GetCallback());
+ EXPECT_FALSE(future.Take().is_allowed());
+}
+
} // namespace content
diff --git a/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc b/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
index cef10733..9f89b33b 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
@@ -7,6 +7,8 @@
#include <memory>
#include "base/functional/callback_helpers.h"
+#include "base/test/test_future.h"
+#include "components/file_access/scoped_file_access.h"
#include "components/file_access/test/mock_scoped_file_access_delegate.h"
#include "content/browser/blob_storage/chrome_blob_storage_context.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -18,6 +20,8 @@
#include "mojo/public/cpp/system/functions.h"
#include "storage/browser/blob/blob_data_builder.h"
#include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
+#include "storage/browser/blob/blob_data_snapshot.h"
#include "storage/browser/blob/blob_storage_constants.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -257,6 +261,64 @@
EXPECT_EQ(expected_blob_data, *handle->CreateSnapshot());
}
Regression Test / PoC
diff --git a/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc b/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
index fdeeadba..f682cc97 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
@@ -5,6 +5,8 @@
#include "content/browser/blob_storage/file_backed_blob_factory_frame_impl.h"
#include "base/test/task_environment.h"
+#include "base/test/test_future.h"
+#include "components/file_access/scoped_file_access.h"
#include "components/file_access/test/mock_scoped_file_access_delegate.h"
#include "content/browser/blob_storage/chrome_blob_storage_context.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -16,6 +18,8 @@
#include "mojo/public/cpp/system/functions.h"
#include "storage/browser/blob/blob_data_builder.h"
#include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
+#include "storage/browser/blob/blob_data_snapshot.h"
#include "storage/browser/blob/blob_storage_constants.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -302,4 +306,66 @@
EXPECT_NE(GURL(kMainFrameUrl), captured_destination);
}
+TEST_F(FileBackedBlobFactoryFrameImplTest,
+ Register_FencedFrameWithScopedFileAccessDelegate) {
+ main_test_rfh()->InitializeRenderFrameIfNeeded();
+ TestRenderFrameHost* fenced_rfh = main_test_rfh()->AppendFencedFrame();
+ EXPECT_TRUE(fenced_rfh);
+ if (!fenced_rfh) {
+ return;
+ }
+ fenced_rfh->SetLastCommittedUrl(GURL(kSubframeUrl));
+
+ mojo::AssociatedRemote<blink::mojom::FileBackedBlobFactory> fenced_factory;
+ FileBackedBlobFactoryFrameImpl::CreateForCurrentDocument(
+ fenced_rfh, fenced_factory.BindNewEndpointAndPassDedicatedReceiver());
+
+ file_access::MockScopedFileAccessDelegate scoped_file_access_delegate;
+ EXPECT_CALL(scoped_file_access_delegate, CreateFileAccessCallback).Times(0);
+
+ const base::FilePath path = base::FilePath(TEST_PATH("/dir/testfile"));
+ ChildProcessSecurityPolicyImpl::GetInstance()->GrantReadFile(
+ fenced_rfh->GetProcess()->GetID(), path);
+ EXPECT_TRUE(ChildProcessSecurityPolicyImpl::GetInstance()->CanReadFile(
+ fenced_rfh->GetProcess()->GetID(), path));
+
+ auto element =
+ blink::mojom::DataElementFile::New(path, kOffset, kSize, std::nullopt);
+
+ mojo::Remote<blink::mojom::Blob> blob;
+ fenced_factory->RegisterBlob(blob.BindNewPipeAndPassReceiver(), kId, kType,
+ std::move(element));
+ fenced_factory.FlushForTesting();
+ blob.FlushForTesting();
+
+ EXPECT_TRUE(bad_messages_.empty());
+
+ auto* blob_storage_context =
+ ChromeBlobStorageContext::GetFor(main_test_rfh()->GetBrowserContext())
+ ->context();
+
+ std::unique_ptr<storage::BlobDataHandle> handle =
+ blob_storage_context->GetBlobDataFromUUID(kId);
+ WaitForBlobCompletion(handle.get());
+
+ EXPECT_FALSE(handle->IsBroken());
+ EXPECT_EQ(storage::BlobStatus::DONE, handle->GetBlobStatus());
+
+ std::unique_ptr<storage::BlobDataSnapshot> snapshot =
+ handle->CreateSnapshot();
+ EXPECT_EQ(1u, snapshot->items().size());
+ if (snapshot->items().size() != 1u) {
+ return;
+ }
+ auto file_access = snapshot->items()[0]->file_access();
+ EXPECT_FALSE(file_access.is_null());
+ if (file_access.is_null()) {
+ return;
+ }
+
+ base::test::TestFuture<file_access::ScopedFileAccess> future;
+ file_access.Run({path}, future.GetCallback());
+ EXPECT_FALSE(future.Take().is_allowed());
+}
+
} // namespace content
diff --git a/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc b/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
index cef10733..9f89b33b 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
@@ -7,6 +7,8 @@
#include <memory>
#include "base/functional/callback_helpers.h"
+#include "base/test/test_future.h"
+#include "components/file_access/scoped_file_access.h"
#include "components/file_access/test/mock_scoped_file_access_delegate.h"
#include "content/browser/blob_storage/chrome_blob_storage_context.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -18,6 +20,8 @@
#include "mojo/public/cpp/system/functions.h"
#include "storage/browser/blob/blob_data_builder.h"
#include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
+#include "storage/browser/blob/blob_data_snapshot.h"
#include "storage/browser/blob/blob_storage_constants.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -257,6 +261,64 @@
EXPECT_EQ(expected_blob_data, *handle->CreateSnapshot());
}
+TEST_F(FileBackedBlobFactoryWorkerImplTest,
+ Register_InvalidUrlWithScopedFileAccessDelegate) {
+ file_access::MockScopedFileAccessDelegate scoped_file_access_delegate;
+ EXPECT_CALL(scoped_file_access_delegate, CreateFileAccessCallback).Times(0);
+
+ // Model a worker bound for an opaque origin: the URL passed to
+ // BindReceiver() is invalid.
+ mojo::Remote<blink::mojom::FileBackedBlobFactory> factory;
+ factory_impl_->BindReceiver(factory.BindNewPipeAndPassReceiver(), GURL());
+
+ const base::FilePath path = base::FilePath(TEST_PATH("/dir/testfile"));
+
+ ChildProcessSecurityPolicyImpl::GetInstance()->GrantReadFile(process_id_,
+ path);
+ EXPECT_TRUE(ChildProcessSecurityPolicyImpl::GetInstance()->CanReadFile(
+ process_id_, path));
+
+ auto element =
+ blink::mojom::DataElementFile::New(path, kOffset, kSize, std::nullopt);
+
+ mojo::Remote<blink::mojom::Blob> blob;
+ factory->RegisterBlob(blob.BindNewPipeAndPassReceiver(), kId, kType,
+ std::move(element));
+ factory.FlushForTesting();
+ blob.FlushForTesting();
+
+ EXPECT_TRUE(bad_messages_.empty());
+
+ auto* blob_storage_context =
+ ChromeBlobStorageContext::GetFor(&context_)->context();
+
+ std::unique_ptr<storage::BlobDataHandle> handle =
+ blob_storage_context->GetBlobDataFromUUID(kId);
+ WaitForBlobCompletion(handle.get());
+
+ EXPECT_FALSE(handle->IsBroken());
+ EXPECT_EQ(storage::BlobStatus::DONE, handle->GetBlobStatus());
+
+ // Because the destination URL is unknown, the registered file item must
+ // carry an explicit access callback that denies access rather than leaving
+ // the decision to the default access path.
+ std::unique_ptr<storage::BlobDataSnapshot> snapshot =
+ handle->CreateSnapshot();
+ EXPECT_EQ(1u, snapshot->items().size());
+ if (snapshot->items().size() != 1u) {
+ return;
+ }
+ auto file_access = snapshot->items()[0]->file_access();
+ EXPECT_FALSE(file_access.is_null());
+ if (file_access.is_null()) {
+ return;
+ }
+
+ base::test::TestFuture<file_access::ScopedFileAccess> future;
+ file_access.Run({path}, future.GetCallback());
+ EXPECT_FALSE(future.Take().is_allowed());
+}
+
TEST_F(FileBackedBlobFactoryWorkerImplTest, MultipleBindings) {
file_access::MockScopedFileAccessDelegate scoped_file_access_delegate;
EXPECT_CALL(scoped_file_access_delegate,
Original Bug Report
Potential DLP bypass via fail-open invalid URLs in FileBackedBlobFactory
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A logic error in FileBackedBlobFactoryBase causes Enterprise Data Loss Prevention (DLP) checks to fail-open when an invalid URL is encountered, such as in Fenced Frames. This allows an untrusted origin to bypass DLP policies and access restricted local files by escalating the access request to the SYSTEM component tier.
Affected files:
content/browser/blob_storage/file_backed_blob_factory_base.ccstorage/browser/file_system/local_file_stream_reader.ccchrome/browser/chromeos/policy/dlp/dlp_scoped_file_access_delegate.cccontent/browser/blob_storage/file_backed_blob_factory_frame_impl.cc
Estimated timestamp from git blame: 2023-11-09
Summary
A potential vulnerability exists in the ChromeOS Data Loss Prevention (DLP) enforcement mechanism that could allow exfiltration of restricted files to untrusted web destinations.
When a file-backed blob is created from a context with an invalid or empty URL (specifically, a Fenced Frame), the system fails to apply restrictive policies. Instead, it defaults to a highly-privileged access level (DlpComponent::SYSTEM), bypassing Enterprise DLP rules.
Technical Details
The vulnerability stems from how the blob storage system interacts with the DLP delegate when an origin lacks a valid URL:
- Invalid URL Handling in Fenced Frames: When a file-backed blob is registered via
content::FileBackedBlobFactoryBase::RegisterBlobSync, it determines the destination URL for DLP checks by callingGetCurrentUrl(). For Fenced Frames,FileBackedBlobFactoryFrameImpl::GetCurrentUrl()explicitly returns an empty, invalidGURL()(seecontent/browser/blob_storage/file_backed_blob_factory_frame_impl.cc:34). - Null Callback Creation:
RegisterBlobSyncpasses this empty URL toGetAccessCallback(). Because the URL is invalid (!url_for_file_access_checks.is_valid()), the helper short-circuits and returnsbase::NullCallback()(seecontent/browser/blob_storage/file_backed_blob_factory_base.cc:31). This null callback is permanently attached to the blob’s file slice. - Fail-Open during Read: When the renderer attempts to read the file (e.g., via
FileReader),LocalFileStreamReader::Openis invoked. It checks for a specificfile_access_callback. Finding it null, it falls back to the default access path:file_access::ScopedFileAccessDelegate::RequestDefaultFilesAccessIO(seestorage/browser/file_system/local_file_stream_reader.cc:130). - Privilege Escalation to SYSTEM: The default access path routes to
DlpScopedFileAccessDelegate::RequestDefaultFilesAccess. In the default ChromeOS configuration, the strict enforcement flagkDataControlsFileAccessDefaultDenyis disabled. Consequently, the logic falls through toRequestFilesAccessForSystem, which sets the destination component todlp::DlpComponent::SYSTEM(seechrome/browser/chromeos/policy/dlp/dlp_scoped_file_access_delegate.cc:167). - Policy Bypass: The ChromeOS DLP daemon grants access for
SYSTEMdestinations, allowing the untrusted Fenced Frame to read and exfiltrate admin-restricted files.
Note: Opaque-origin workers also return an empty URL in this context, but it is currently unclear if they possess the necessary web APIs (like <input type="file">) to trigger the initial RegisterBlob call for a local file path.
Potential Attack Steps
(Note: These are suggested steps; our tooling agent does not run code to verify them.)
- An attacker embeds a
<fencedframe>on their malicious website. - Inside the Fenced Frame, the attacker presents a file picker (
<input type="file">) to the user. - The user is tricked into selecting an Enterprise DLP-protected file from their local ChromeOS file system.
- The browser registers the file-backed blob, but due to the Fenced Frame context, it assigns a null DLP access callback.
- The attacker’s JavaScript inside the Fenced Frame reads the file contents (e.g., using
File.text()). The read succeeds because the null callback forces a fallback toSYSTEMlevel access, bypassing the DLP block. - The attacker exfiltrates the sensitive file data.
Recommendation
GetAccessCallback in content/browser/blob_storage/file_backed_blob_factory_base.cc should be modified. Instead of returning base::NullCallback() when a valid destination URL cannot be determined, it should return a callback that explicitly denies access or applies the most restrictive default policy.
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.