CVE-2026-7916
Overview
Background
- Protected Audiences / FLEDGE
- Chrome’s on-device ad-auction API in which untrusted “worklet” JavaScript runs in a sandboxed process and issues a constrained set of network fetches (bidding/scoring scripts, WASM, and trusted-signals).
- `AuctionURLLoaderFactoryProxy`
- the browser-process proxy that intercepts worklet-originated network requests and enforces which URLs, methods, and bodies the worklet is permitted to send.
- `network::ResourceRequestBody` / `DataElement`
- the representation of a request upload body as a list of elements, each tagged (
network::DataElement::Tag) askBytes, a file, a data pipe, etc. - Trusted-signals KVv2 POST
- the one legitimate worklet code path that issues a POST with a body (a single in-memory byte blob), as opposed to the normal GET fetches.
Root Cause Analysis
The request-vetting code in auction_url_loader_factory_proxy.cc previously validated the URL/Accept header but did not constrain the HTTP method or the shape of the upload body for worklet-originated requests.
The patch adds a block that, for any method other than kGetMethod, sets is_request_allowed = false unless the method is exactly kPostMethod and is_trusted_signals_request, and even then only permits a request_body consisting of a single element whose type is network::DataElement::Tag::kBytes. The invariant being restored is that a sandboxed worklet may only emit the request shapes its own code can legitimately produce — GETs, or a trusted-signals POST carrying one in-memory byte blob — and must never drive arbitrary methods (PUT, HEAD, etc.) or bodies containing files or multiple/zero elements. As the inline comment states, “the most important thing here is to disallow attempts to upload files,” i.e., preventing a compromised or malicious worklet from exfiltrating local files or crashing on unexpected body shapes. The second hunk’s added comment (“Note that GET request bodies are ignored”) clarifies that GET bodies are dropped when the request is rebuilt. The change strictly tightens validation; the regression tests assert that PUT/HEAD, multi-element bodies, empty bodies, and file-range bodies are all rejected while a single-byte POST is allowed.
kBytes-element trusted-signals POSTs.Attack Path
- Run worklet code A site enters an ad auction so attacker-controlled worklet JavaScript executes in the Protected Audiences sandbox.
- Forge a request shape
The worklet (or a compromised worklet process) issues a network request with a non-GET method or a POST whose
request_bodycontains a file element, multiple elements, or is empty — shapes the legitimate auction code never produces. - Reach the proxy unvalidated (pre-patch)
AuctionURLLoaderFactoryProxyforwarded the request because it did not check the method or body composition, so the body (e.g., a file upload) was passed through to the network service. - Abuse the upload
This could allow exfiltration of local files via
AppendFileRange-style bodies or trigger mishandling of unexpected body shapes; the diff demonstrates the rejection logic and notes crash-avoidance for empty/multi-element bodies, so any concrete file-read or memory-safety outcome beyond that is(inferred).
Impact Assessment
(inferred) from the comments and tests rather than demonstrated end-to-end. The advisory does not report in-the-wild exploitation.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
Audit Directions
- Request shape beyond URLIn every per-feature
URLLoaderFactoryproxy, verify that method andrequest_bodyelement type/count are validated, not just the URL andAcceptheader, especially for sandboxed/untrusted request sources. - File-element uploadsGrep for request bodies passed through without checking
DataElement::Tag, and ensurekFile/data-pipe elements are rejected on paths that should only carry in-memory bytes. - Allowlist completeness for methodsWhere only GET is expected, confirm non-GET methods (
PUT,HEAD,DELETE) are explicitly rejected rather than implicitly forwarded, and that empty/multi-element bodies cannot crash the handler.
Patch
From 063cf5bbbc1c238c6189cb89ef3809beae810e62 Mon Sep 17 00:00:00 2001
From: Matt Menke <mmenke@chromium.org>
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 <morlovich@chromium.org>
Commit-Queue: mmenke <mmenke@chromium.org>
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.