CVE-2026-7916
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/interest_group/auction_url_loader_factory_proxy.cc |
modified | |
ifcontent/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc |
modified |
Files Changed
content/browser/interest_group/auction_url_loader_factory_proxy.cccontent/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc
Patch
From 063cf5bbbc1c238c6189cb89ef3809beae810e62 Mon Sep 17 00:00:00 2001 From: Matt Menke <[email protected]> Date: Thu, 02 Apr 2026 13:03:05 -0700 Subject: [PATCH] [Protected Audiences] Add some checks around request upload bodies. And methods as well. Fixed: 498720754 Change-Id: I3339444bdc1da51f7b308e3e5e059106c2fb751f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7726850 Reviewed-by: Maks Orlovich <[email protected]> Commit-Queue: mmenke <[email protected]> Cr-Commit-Position: refs/heads/main@{#1609453} --- diff --git a/content/browser/interest_group/auction_url_loader_factory_proxy.cc b/content/browser/interest_group/auction_url_loader_factory_proxy.cc index 8c6aa171..01e6f94 100644 --- a/content/browser/interest_group/auction_url_loader_factory_proxy.cc +++ b/content/browser/interest_group/auction_url_loader_factory_proxy.cc @@ -38,7 +38,9 @@ #include "net/cookies/site_for_cookies.h" #include "net/http/http_request_headers.h" #include "net/traffic_annotation/network_traffic_annotation.h" +#include "services/network/public/cpp/data_element.h" #include "services/network/public/cpp/resource_request.h" +#include "services/network/public/cpp/resource_request_body.h" #include "services/network/public/mojom/cookie_manager.mojom-shared.h" #include "services/network/public/mojom/url_loader_factory.mojom.h" #include "third_party/blink/public/common/features.h" @@ -179,6 +181,26 @@ } } + if (url_request.method != net::HttpRequestHeaders::kGetMethod) { + // If the request is not a GET and not a trusted signals POST, disallow the + // request. + if (url_request.method != net::HttpRequestHeaders::kPostMethod || + !is_trusted_signals_request) { + is_request_allowed = false; + } else { + // For trusted signals POSTs only allow request bodies that contain a + // single byte element, since that's all the auction worklet code should + // produce. The most important thing here is to disallow attempts to + // upload files. + if (url_request.request_body && + (url_request.request_body->elements()->size() != 1u || + url_request.request_body->elements()->front().type() != + network::DataElement::Tag::kBytes)) { + is_request_allowed = false; + } + } + } + if (!is_request_allowed) { // Debugging for https://crbug.com/1448458 SCOPED_CRASH_KEY_STRING32("fledge", "req-accept", accept_header); @@ -218,6 +240,7 @@ if (url_request.method == net::HttpRequestHeaders::kPostMethod) { new_request.method = std::move(url_request.method); + // Note that GET request bodies are ignored. new_request.request_body = std::move(url_request.request_body); std::optional<std::string> content_type = url_request.headers.GetHeader(net::HttpRequestHeaders::kContentType); diff --git a/content/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc b/content/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc index f401ce8b..fffaa6c 100644 --- a/content/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc +++ b/content/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc @@ -8,6 +8,7 @@ #include <vector> +#include "base/containers/span.h" #include "base/functional/bind.h" #include "base/functional/callback_helpers.h" #include "base/memory/ref_counted.h" @@ -31,7 +32,9 @@ #include "net/http/http_request_headers.h" #include "net/traffic_annotation/network_traffic_annotation.h" #include "net/traffic_annotation/network_traffic_annotation_test_helper.h" +#include "services/network/public/cpp/data_element.h" #include "services/network/public/cpp/resource_request.h" +#include "services/network/public/cpp/resource_request_body.h" #include "services/network/public/mojom/client_security_state.mojom.h" #include "services/network/public/mojom/ip_address_space.mojom.h" #include "services/network/public/mojom/url_loader_factory.mojom.h" @@ -351,7 +354,26 @@ // Check method, body and content-type for POST requests. if (request.method == net::HttpRequestHeaders::kPostMethod) { EXPECT_EQ(observed_request.method, net::HttpRequestHeaders::kPostMethod); - EXPECT_EQ(request.request_body, observed_request.request_body); + ASSERT_EQ(!!request.request_body, !!observed_request.request_body); + // If there's a request body, both bodies should both have a single + // matching element of type kBytes. + if (request.request_body) { + const auto& request_elements = *request.request_body->elements(); + const auto& observed_elements = + *observed_request.request_body->elements(); + ASSERT_EQ(request_elements.size(), 1u); + ASSERT_EQ(observed_elements.size(), 1u); + ASSERT_EQ(request_elements.front().type(), + network::DataElement::Tag::kBytes); + ASSERT_EQ(observed_elements.front().type(), + network::DataElement::Tag::kBytes); + EXPECT_EQ(request_elements.front() + .As<network::DataElementBytes>() + .AsStringPiece(), + observed_elements.front() + .As<network::DataElementBytes>() + .AsStringPiece()); + } if (request.headers.GetHeader(net::HttpRequestHeaders::kContentType) .has_value()) { EXPECT_EQ( @@ -574,6 +596,24 @@ ExpectedResponse::kReject); TryMakeRequest("https://host.test/", std::nullopt, ExpectedResponse::kReject); + + // Methods other than GET should be rejected for non-signals URLs. Method + // logic for signals URLs is checked further down. + network::ResourceRequest request; + request.url = GURL(kScriptUrl); + request.headers.SetHeader(net::HttpRequestHeaders::kAccept, + kAcceptJavascript); + request.method = net::HttpRequestHeaders::kPostMethod; + TryMakeRequest(request, ExpectedResponse::kReject); + request.method = net::HttpRequestHeaders::kHeadMethod; + TryMakeRequest(request, ExpectedResponse::kReject); + + request.url = GURL(kWasmUrl); + request.headers.SetHeader(net::HttpRequestHeaders::kAccept, kAcceptWasm); + request.method = net::HttpRequestHeaders::kPostMethod; + TryMakeRequest(request, ExpectedResponse::kReject); + request.method = net::HttpRequestHeaders::kHeadMethod; + TryMakeRequest(request, ExpectedResponse::kReject); } } @@ -748,9 +788,11 @@ "https://host.test/trusted_signals?hostname=top.test&keys=%23%26%3D", kAcceptJson, ExpectedResponse::kAllow); - // Valid Trusted Signals KVv2 POST request + // Valid Trusted Signals KVv2 POST request. network::ResourceRequest request; request.method = net::HttpRequestHeaders::kPostMethod; + request.request_body = network::ResourceRequestBody::CreateFromCopyOfBytes( + base::as_byte_span("1234")); request.url = GURL(kTrustedSignalsBaseUrl); request.headers.SetHeader(net::HttpRequestHeaders::kAccept, kAcceptAdAuctionTrustedSignals); @@ -758,6 +800,35 @@ kAdAuctionTrustedSignalsContentType); TryMakeRequest(request, ExpectedResponse::kAllow); + // Methods other than GET and POST are rejected. + request.method = net::HttpRequestHeaders::kPutMethod; + TryMakeRequest(request, ExpectedResponse::kReject); + request.method = net::HttpRequestHeaders::kHeadMethod; + TryMakeRequest(request, ExpectedResponse::kReject); + // Restore method. + request.method = net::HttpRequestHeaders::kPostMethod; + + // Uploads with multiple elements should be rejected. This isn't too + // important, but need to make sure they don't crash. + request.request_body->AppendCopyOfBytes(base::as_byte_span("5678")); + TryMakeRequest(request, ExpectedResponse::kReject); + + // Empty uploads should be rejected. This case is mostly to make sure they + // don't result in crashes. + request.request_body = base::MakeRefCounted<network::ResourceRequestBody>(); + TryMakeRequest(request, ExpectedResponse::kReject); + + // Uploads of files should be rejected. There's no code wired up to actually + // read the file, so don't need a path to a real file. + request.request_body->AppendFileRange( + base::FilePath(), /*offset=*/0u, /*length=*/2u, + /*expected_modification_time=*/base::Time()); + TryMakeRequest(request, ExpectedResponse::kReject); + + // Restore the upload body. + request.request_body = network::ResourceRequestBody::CreateFromCopyOfBytes( + base::as_byte_span("1234")); + // Invalid Trusted Signals KVv2 POST request with mismatched base url. request.url = GURL("https://host.test/trusted_signals?"); TryMakeRequest(request, ExpectedResponse::kReject);
Regression Test / PoC
diff --git a/content/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc b/content/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc
index f401ce8b..fffaa6c 100644
--- a/content/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc
+++ b/content/browser/interest_group/auction_url_loader_factory_proxy_unittest.cc
@@ -8,6 +8,7 @@
#include <vector>
+#include "base/containers/span.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/memory/ref_counted.h"
@@ -31,7 +32,9 @@
#include "net/http/http_request_headers.h"
#include "net/traffic_annotation/network_traffic_annotation.h"
#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
+#include "services/network/public/cpp/data_element.h"
#include "services/network/public/cpp/resource_request.h"
+#include "services/network/public/cpp/resource_request_body.h"
#include "services/network/public/mojom/client_security_state.mojom.h"
#include "services/network/public/mojom/ip_address_space.mojom.h"
#include "services/network/public/mojom/url_loader_factory.mojom.h"
@@ -351,7 +354,26 @@
// Check method, body and content-type for POST requests.
if (request.method == net::HttpRequestHeaders::kPostMethod) {
EXPECT_EQ(observed_request.method, net::HttpRequestHeaders::kPostMethod);
- EXPECT_EQ(request.request_body, observed_request.request_body);
+ ASSERT_EQ(!!request.request_body, !!observed_request.request_body);
+ // If there's a request body, both bodies should both have a single
+ // matching element of type kBytes.
+ if (request.request_body) {
+ const auto& request_elements = *request.request_body->elements();
+ const auto& observed_elements =
+ *observed_request.request_body->elements();
+ ASSERT_EQ(request_elements.size(), 1u);
+ ASSERT_EQ(observed_elements.size(), 1u);
+ ASSERT_EQ(request_elements.front().type(),
+ network::DataElement::Tag::kBytes);
+ ASSERT_EQ(observed_elements.front().type(),
+ network::DataElement::Tag::kBytes);
+ EXPECT_EQ(request_elements.front()
+ .As<network::DataElementBytes>()
+ .AsStringPiece(),
+ observed_elements.front()
+ .As<network::DataElementBytes>()
+ .AsStringPiece());
+ }
if (request.headers.GetHeader(net::HttpRequestHeaders::kContentType)
.has_value()) {
EXPECT_EQ(
@@ -574,6 +596,24 @@
ExpectedResponse::kReject);
TryMakeRequest("https://host.test/", std::nullopt,
ExpectedResponse::kReject);
+
+ // Methods other than GET should be rejected for non-signals URLs. Method
+ // logic for signals URLs is checked further down.
+ network::ResourceRequest request;
+ request.url = GURL(kScriptUrl);
+ request.headers.SetHeader(net::HttpRequestHeaders::kAccept,
+ kAcceptJavascript);
+ request.method = net::HttpRequestHeaders::kPostMethod;
+ TryMakeRequest(request, ExpectedResponse::kReject);
+ request.method = net::HttpRequestHeaders::kHeadMethod;
+ TryMakeRequest(request, ExpectedResponse::kReject);
+
+ request.url = GURL(kWasmUrl);
+ request.headers.SetHeader(net::HttpRequestHeaders::kAccept, kAcceptWasm);
+ request.method = net::HttpRequestHeaders::kPostMethod;
+ TryMakeRequest(request, ExpectedResponse::kReject);
+ request.method = net::HttpRequestHeaders::kHeadMethod;
+ TryMakeRequest(request, ExpectedResponse::kReject);
}
}
@@ -748,9 +788,11 @@
"https://host.test/trusted_signals?hostname=top.test&keys=%23%26%3D",
kAcceptJson, ExpectedResponse::kAllow);
- // Valid Trusted Signals KVv2 POST request
+ // Valid Trusted Signals KVv2 POST request.
network::ResourceRequest request;
request.method = net::HttpRequestHeaders::kPostMethod;
+ request.request_body = network::ResourceRequestBody::CreateFromCopyOfBytes(
+ base::as_byte_span("1234"));
request.url = GURL(kTrustedSignalsBaseUrl);
request.headers.SetHeader(net::HttpRequestHeaders::kAccept,
kAcceptAdAuctionTrustedSignals);
@@ -758,6 +800,35 @@
kAdAuctionTrustedSignalsContentType);
TryMakeRequest(request, ExpectedResponse::kAllow);
+ // Methods other than GET and POST are rejected.
+ request.method = net::HttpRequestHeaders::kPutMethod;
+ TryMakeRequest(request, ExpectedResponse::kReject);
+ request.method = net::HttpRequestHeaders::kHeadMethod;
+ TryMakeRequest(request, ExpectedResponse::kReject);
+ // Restore method.
+ request.method = net::HttpRequestHeaders::kPostMethod;
+
+ // Uploads with multiple elements should be rejected. This isn't too
+ // important, but need to make sure they don't crash.
+ request.request_body->AppendCopyOfBytes(base::as_byte_span("5678"));
+ TryMakeRequest(request, ExpectedResponse::kReject);
+
+ // Empty uploads should be rejected. This case is mostly to make sure they
+ // don't result in crashes.
+ request.request_body = base::MakeRefCounted<network::ResourceRequestBody>();
+ TryMakeRequest(request, ExpectedResponse::kReject);
+
+ // Uploads of files should be rejected. There's no code wired up to actually
+ // read the file, so don't need a path to a real file.
+ request.request_body->AppendFileRange(
+ base::FilePath(), /*offset=*/0u, /*length=*/2u,
+ /*expected_modification_time=*/base::Time());
+ TryMakeRequest(request, ExpectedResponse::kReject);
+
+ // Restore the upload body.
+ request.request_body = network::ResourceRequestBody::CreateFromCopyOfBytes(
+ base::as_byte_span("1234"));
+
// Invalid Trusted Signals KVv2 POST request with mismatched base url.
request.url = GURL("https://host.test/trusted_signals?");
TryMakeRequest(request, ExpectedResponse::kReject);
Original Bug Report
Arbitrary File Read Sandbox Escape in AuctionURLLoaderFactoryProxy
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.
Overview: A compromised FLEDGE auction worklet can potentially read arbitrary local files, escaping the sandbox. The AuctionURLLoaderFactoryProxy blindly forwards attacker-controlled POST request bodies to a browser-privileged URLLoaderFactory. By embedding a DataElementFile in the request body, the worklet can trick the browser into opening and uploading local files to an attacker-controlled server.
Affected files:
content/browser/interest_group/auction_url_loader_factory_proxy.cccontent/browser/interest_group/ad_auction_service_impl.cccontent/browser/network_context_client_base_impl.cc
Estimated timestamp from git blame: 2024-12-10
Vulnerability Description
A confused deputy vulnerability exists in the browser-side AuctionURLLoaderFactoryProxy that can potentially allow a compromised sandboxed auction worklet (e.g., compromised via a V8 bug) to achieve an arbitrary file read on the user’s system.
The AuctionURLLoaderFactoryProxy::CreateLoaderAndStart function mediates network requests from the worklet. To allow legitimate requests, it enforces a URL allowlist (e.g., script_url_) and expects certain Accept headers. However, if the worklet issues a POST request, the proxy copies the request_body directly into the outgoing new_request without any sanitization:
// content/browser/interest_group/auction_url_loader_factory_proxy.cc:219
if (url_request.method == net::HttpRequestHeaders::kPostMethod) {
new_request.method = std::move(url_request.method);
new_request.request_body = std::move(url_request.request_body); // VULNERABILITY: Unsanitized copy
// ...
}
This request_body can maliciously contain a DataElementFile referencing any local file path on the disk (e.g., /home/user/.ssh/id_rsa).
The proxy forwards this new_request using its underlying trusted URLLoaderFactory. This factory is created in AdAuctionServiceImpl::CreateUnderlyingTrustedURLLoaderFactory using url_loader_factory::TerminalParams::ForBrowserProcess(...). Because it’s designated as a browser-process factory, its process_id is set to network::OriginatingProcessId::browser().
When the Network Service receives this request, it attempts to read the file specified in the DataElementFile for the POST upload. It sends an OnFileUploadRequested IPC back to the browser process. In NetworkContextClientBase::OnFileUploadRequested, the browser checks if the process has read access:
// content/browser/network_context_client_base_impl.cc:41
if (!process_id.is_browser() &&
!cpsp->CanReadFile(ToChildProcessId(process_id.renderer_process_id()), file_path)) {
// Access check performed here
}
Since process_id.is_browser() is true, the ChildProcessSecurityPolicy::CanReadFile check is completely bypassed. The browser opens the requested local file and returns the handle to the Network Service, which then uploads the file’s contents to the attacker’s server.
Potential Reproduction Steps
Note: These are suggested/potential steps based on code analysis; our tooling agent does not currently have the ability to run code to verify a working proof-of-concept.
- An attacker compromises an auction worklet process (e.g.,
kServiceWithJitsandbox). - The attacker invokes
CreateLoaderAndStarton theURLLoaderFactoryremote bound to theAuctionURLLoaderFactoryProxyin the browser process. - The attacker crafts a
ResourceRequestwhere:urlis set to the worklet’s own allowedscript_url.methodisPOST.headersincludesAccept: application/javascriptto pass proxy validation.request_bodycontains aDataElementFilepointing to a target local file.
- The proxy forwards the request via its browser-privileged
URLLoaderFactory. - The Network Service asks the browser to open the file. The browser complies because the request is marked as originating from the browser process.
- The file is uploaded as the POST payload to the attacker’s script URL endpoint.
Suggested Fix
In AuctionURLLoaderFactoryProxy::CreateLoaderAndStart, explicitly sanitize the request_body to ensure it does not contain any DataElementFile or DataElementDataPipe elements that the worklet should not have access to. If the request_body contains a file element, the proxy should immediately reject the request by calling receiver_.ReportBadMessage("Invalid request body");.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
Results 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.