CVE-2026-87515
Overview
Background
- `GetAccessCallback`
- a free function in
content/browser/blob_storage/file_backed_blob_factory_base.ccthat returns afile_access::ScopedFileAccessDelegate::RequestFilesAccessIOCallbackused to gate access to a file-backed blob’s underlying file. - `file_access::ScopedFileAccessDelegate`
- a process-wide, optionally-installed delegate that performs per-destination file-access checks (e.g. enterprise data-leak-prevention policy) when present, queried via
HasInstance()andGet(). - `base::NullCallback()` vs. a denying callback
- a null callback signals “no destination known” so downstream consumers fall back to a default access path, whereas a callback that invokes its continuation with
file_access::ScopedFileAccess::Denied()explicitly refuses access. - `FileBackedBlobFactoryFrameImpl::GetCurrentUrl`
- returns the destination URL used for the access check, and deliberately returns an invalid
GURL()for frames nested within a fenced frame (per the updated comment, also relevant to opaque-origin workers).
Root Cause Analysis
Before the patch, GetAccessCallback combined two unrelated early-exit conditions into a single guard: if (!file_access::ScopedFileAccessDelegate::HasInstance() || !url_for_file_access_checks.is_valid()) returned base::NullCallback(). That conflated “no delegate is installed, so no per-destination checks apply” with “a delegate is installed but the destination URL is invalid.” As the commit message states, downstream consumers treat a null callback as “no destination known” and route to the default access path (inferred as an allow/unrestricted path), so an invalid destination URL bypassed the per-destination check even while a ScopedFileAccessDelegate was actively enforcing policy.
The fix separates the conditions: it still returns base::NullCallback() only when !HasInstance(), and now, when a delegate is installed and !url_for_file_access_checks.is_valid(), returns a base::BindRepeating callback that runs its continuation with file_access::ScopedFileAccess::Denied(). This closes the gap because an unknown/invalid destination (fenced frames, opaque-origin workers) is treated as deny rather than fall-through-to-default.
The patch only reclassifies the failure path (null → deny); it adds no new sink handling.
Attack Path
- Establish a delegate-gated context
On a build/profile where a
file_access::ScopedFileAccessDelegateis installed (per-destination file-access policy is being enforced), the attacker operates from a renderer whose destination URL evaluates as invalid, e.g. a fenced frame (GetCurrentUrlreturnsGURL()) or a worker bound for an opaque origin (BindReceiver()called withGURL()), as modeled by the added tests. - Register a file-backed blob
From that context the attacker calls
RegisterBlobwith aDataElementFilepointing at a file path the renderer process already holds aChildProcessSecurityPolicyImpl::GrantReadFilegrant for. - Hit the conflated guard
GetAccessCallbackis reached with an invalidurl_for_file_access_checks; pre-patch it returnsbase::NullCallback()because the invalid URL satisfies the combined||condition. - Bypass the per-destination check The blob’s file item carries a null access callback, so downstream consumers route to the default access path instead of consulting the delegate, granting file access the delegate’s per-destination policy was meant to deny (inferred: the default path permits access). Any escalation beyond reading the delegate-restricted file’s contents is (inferred).
Impact Assessment
ScopedFileAccessDelegate’s per-destination policy was supposed to block, i.e. an incorrect-authorization / policy-bypass in the FileAPI path within the browser process’s blob-registration logic. Preconditions: a ScopedFileAccessDelegate must be installed, the renderer must already hold a valid read grant for the target file, and the destination URL must evaluate as invalid. The advisory classifies this as incorrect authorization in FileAPI and reports no exploited-in-the-wild status; any read of otherwise-restricted data or further escalation is (inferred), as the patch demonstrates only that the access callback was null instead of denying.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 | |
TEST_Fcontent/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc |
modified |
Files Changed
content/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.cccontent/browser/blob_storage/file_backed_blob_factory_base.cc
Audit Directions
- Conflated early-return conditionsSearch for guards that OR together an “authority not present” check and a “context/identifier invalid” check into one return; the invalid-context case may silently inherit the permissive behavior intended only for the no-authority case.
- Null-callback-as-fallback semanticsAudit callers of
ScopedFileAccessDelegate::RequestFilesAccessIOCallbackand similar optional-callback APIs where a null value means “default/allow”; ensure every “cannot determine destination” path returns an explicit deny rather than null. - Invalid-destination contextsReview other sites that pass fenced-frame or opaque-origin URLs (empty/invalid
GURL()) into authorization checks, confirming an unknown destination is treated as deny across FileAPI and adjacent capability grants.
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());
}
+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,
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.