CVE-2026-79067
Overview
Background
- Keepalive / `fetchLater` loaders
fetch(..., {keepalive: true})andfetchLater()requests are proxied through the browser process byKeepAliveURLLoaderServiceandKeepAliveURLLoader, so they can outlive the initiating document.- `ResourceRequest::fetch_retry_options`
- an optional, renderer-supplied field that asks the browser-side keepalive loader to retry a failed request automatically. It is part of the experimental
FetchRetryfeature. - `blink::features::kFetchRetry` and Origin Trial state
- the global feature gate, plus a per-document runtime feature state (
RuntimeFeatureStateDocumentData) that can enableFetchRetryfor a document through an Origin Trial. - `mojo::ReportBadMessage`
- rejects a malformed or unauthorized IPC and leads to the sending renderer being terminated.
Root Cause Analysis
According to the commit message, Blink only fills in fetch_retry_options when the FetchRetry runtime feature is enabled. The browser side never re-checked that. KeepAliveURLLoaderFactoriesBase’s loader-creation path checked other renderer-supplied fields (such as trusted_params) but accepted any request carrying fetch_retry_options. KeepAliveURLLoader::IsEligibleForRetry() returned false only when retry_options was absent and never consulted kFetchRetry.
As a result, the browser process would honour retry requests from a renderer whose document had no access to the feature, or even when the feature was globally disabled. The invariant that was broken is that a capability gated by a feature flag or Origin Trial must be authorized in the browser, not only in the renderer.
The fix adds FactoryContext::IsFetchRetryEnabled(). It returns false unless kFetchRetry is enabled, returns true if the feature is explicitly overridden on, and otherwise reads the initiator document’s runtime feature state, caching the result in cached_is_fetch_retry_enabled. The loader-creation path now calls ReportBadMessage and returns nullptr when fetch_retry_options is set but IsFetchRetryEnabled() is false, and IsEligibleForRetry() also refuses to retry when kFetchRetry is disabled. To support the check, runtime_enabled_features.json5 gives the browser read access to the FetchRetry state, and the cache is seeded when a navigation commits so the state survives document unload.
fetch_retry_options.Attack Path
- Compromise a renderer
The attacker needs control of a renderer, because a well-behaved Blink only sets
fetch_retry_optionswhenFetchRetryis enabled for the document. The unit tests model this by sending the field directly over the factory interface. - Send a keepalive or `fetchLater` request with retry options
The compromised renderer calls the keepalive loader factory with a
ResourceRequestwhosefetch_retry_optionsis set, for a document that lacks the Origin Trial or whilekFetchRetryis disabled. - Browser accepts the request
Before the patch, the loader-creation path in
KeepAliveURLLoaderFactoriesBaseperformed noFetchRetrycheck, so aKeepAliveURLLoaderwas created with the retry options. - Browser-driven retries
When the request failed,
IsEligibleForRetry()treated it as eligible because options were present, so the browser process could re-send the request on the renderer’s behalf, possibly after the document unloaded. This gives access to a capability the document was not granted (impact beyond the unauthorized retry is inferred).
Impact Assessment
fetch_retry_options while the feature is off. The advisory states only “Missing authorization in Network” and does not report in-the-wild exploitation, and the patch shows no memory-safety consequence.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcontent/browser/loader/keep_alive_url_loader_service_unittest.cc |
modified |
Files Changed
third_party/blink/renderer/platform/runtime_enabled_features.json5content/browser/loader/keep_alive_url_loader_service_unittest.cccontent/browser/loader/keep_alive_url_loader_service.hcontent/browser/loader/keep_alive_url_loader_service.cccontent/browser/loader/keep_alive_url_loader.cc
Audit Directions
- Renderer-gated fields accepted unconditionally in the browserAudit
ResourceRequestand other Mojo structs for optional fields that Blink only sets behind a runtime feature or Origin Trial (for example new fetch options, priority hints, attribution or privacy features). Check that browser or network-service consumers re-verify the gate rather than trusting that the field is present. - Feature checks only at creation time or only at use timeMake sure gated behaviour is checked both when the IPC is accepted and at the point of action, and that cached per-document state is not taken from the wrong document when factories are reused across navigations.
- Origin Trial state readable in the browserLook for Origin-Trial-gated Blink features without
browser_process_read_accesswhose browser-side handlers therefore cannot check the document’s actual enablement.
Patch
From 279e99c17920f2fefb34755efb23cda12f6c92d1 Mon Sep 17 00:00:00 2001
From: Ming-Ying Chung <mych@chromium.org>
Date: Wed, 12 Aug 2026 19:32:39 -0700
Subject: [PATCH] [fetch-retry]] Validate fetch_retry_options against feature
The renderer only sets ResourceRequest::fetch_retry_options when the
FetchRetry runtime feature is enabled. However, the browser-side
KeepAliveURLLoaderFactoriesBase::CreateKeepAliveURLLoader did not
re-check the feature or its Origin Trial state before honoring it.
This CL adds the missing browser-side check to validate both the global
feature gate (blink::features::kFetchRetry) and the document-specific
Origin Trial state. If fetch_retry_options is set but the feature is
disabled, the request is rejected via mojo::ReportBadMessage.
Additionally, this CL adds a feature check in
KeepAliveURLLoader::IsEligibleForRetry to prevent retries if the feature
is disabled.
Bug: 513049445
Change-Id: I7401949a8f791b074360ef02204a9c02c0101c13
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8181647
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Reviewed-by: Justin Novosad <junov@chromium.org>
Commit-Queue: Ming-Ying Chung <mych@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1678546}
---
diff --git a/content/browser/loader/keep_alive_url_loader.cc b/content/browser/loader/keep_alive_url_loader.cc
index 64f8c99..9d6535e 100644
--- a/content/browser/loader/keep_alive_url_loader.cc
+++ b/content/browser/loader/keep_alive_url_loader.cc
@@ -914,8 +914,9 @@
bool KeepAliveURLLoader::IsEligibleForRetry(
std::optional<network::URLLoaderCompletionStatus> completion_status) const {
auto retry_options = resource_request_.fetch_retry_options;
- if (!retry_options.has_value()) {
- // The fetch must opt-in to retry.
+ if (!retry_options.has_value() ||
+ !base::FeatureList::IsEnabled(blink::features::kFetchRetry)) {
+ // The fetch must opt-in to retry and the feature must be enabled.
return false;
}
diff --git a/content/browser/loader/keep_alive_url_loader_service.cc b/content/browser/loader/keep_alive_url_loader_service.cc
index 83768c3..6206e1f 100644
--- a/content/browser/loader/keep_alive_url_loader_service.cc
+++ b/content/browser/loader/keep_alive_url_loader_service.cc
@@ -19,6 +19,7 @@
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/render_frame_host.h"
+#include "content/public/browser/runtime_feature_state/runtime_feature_state_document_data.h"
#include "content/public/browser/url_loader_throttles.h"
#include "content/public/browser/web_contents.h"
#include "mojo/public/cpp/bindings/associated_receiver_set.h"
@@ -73,6 +74,13 @@
ukm_source_id = navigation_handle->GetNextPageUkmSourceId();
policy_container_host = rfh->policy_container_host();
CHECK(policy_container_host);
+
+ cached_is_fetch_retry_enabled.reset();
+ // Cache the feature flag for the newly committed document while
+ // `RenderFrameHostImpl` is guaranteed valid. This preserves feature
+ // state for requests dispatched during document unload whose IPCs arrive
+ // post-destruction.
+ std::ignore = IsFetchRetryEnabled();
}
bool KeepAliveURLLoaderService::FactoryContext::WasInitiatorDocumentDestroyed()
@@ -95,6 +103,30 @@
factory = new_factory;
}
+bool KeepAliveURLLoaderService::FactoryContext::IsFetchRetryEnabled() const {
+ if (!base::FeatureList::IsEnabled(blink::features::kFetchRetry)) {
+ return false;
+ }
+ std::optional<bool> overridden_state =
+ base::FeatureList::GetStateIfOverridden(blink::features::kFetchRetry);
+ if (overridden_state == std::make_optional(true)) {
+ return true;
+ }
+ if (cached_is_fetch_retry_enabled.has_value()) {
+ return *cached_is_fetch_retry_enabled;
+ }
+ if (auto* rfh = static_cast<RenderFrameHostImpl*>(
+ weak_document_ptr.AsRenderFrameHostIfValid())) {
+ if (auto* document_data =
+ RuntimeFeatureStateDocumentData::GetForCurrentDocument(rfh)) {
+ cached_is_fetch_retry_enabled =
+ document_data->runtime_feature_state_read_context()
+ .IsFetchRetryEnabled();
+ }
+ }
+ return cached_is_fetch_retry_enabled.value_or(false);
+}
+
// KeepAliveURLLoaderFactoriesBase is an abstract base class for creating and
// managing all the KeepAliveURLLoader instances created by multiple factories
// of the same `Interface`.
@@ -224,6 +256,15 @@
"resource_request.trusted_params must not be set");
return nullptr;
}
+ if (resource_request.fetch_retry_options.has_value() &&
+ !context->IsFetchRetryEnabled()) {
+ mojo::ReportBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+ return nullptr;
+ }
// Notifies RenderFrameHostImpl (if any) that a fetch keepalive request is
// created.
diff --git a/content/browser/loader/keep_alive_url_loader_service.h b/content/browser/loader/keep_alive_url_loader_service.h
index e6f7010..3729af4 100644
--- a/content/browser/loader/keep_alive_url_loader_service.h
+++ b/content/browser/loader/keep_alive_url_loader_service.h
@@ -100,6 +100,12 @@
void UpdateFactory(
scoped_refptr<network::SharedURLLoaderFactory> new_factory);
+ // Returns true if the `FetchRetry` feature is enabled for the document
+ // associated with this context (or was enabled before the document
+ // unloaded) and the global feature flag is enabled. Lazily computes and
+ // caches the result.
+ bool IsFetchRetryEnabled() const;
+
// The factory to use for the requests initiated from this context.
scoped_refptr<network::SharedURLLoaderFactory> factory;
@@ -141,6 +147,12 @@
// been invalidated.
bool did_commit_navigation = false;
+ // Caches whether the FetchRetry feature is enabled for the initiator
+ // document. Lazily computed by `IsFetchRetryEnabled()` while the document's
+ // `RenderFrameHostImpl` is active, preserving feature state if the document
+ // unloads before keepalive request IPCs arrive.
+ mutable std::optional<bool> cached_is_fetch_retry_enabled;
+
// This must be the last member.
base::WeakPtrFactory<FactoryContext> weak_ptr_factory{this};
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index 6995c8c..589598b6 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -2903,6 +2903,7 @@
// Enables the Blink feature only when the base::Feature is overridden by
// field trial or command line.
copied_from_base_feature_if: "overridden",
+ browser_process_read_access: true,
},
{
name: "FetchUploadStreaming",
diff --git a/content/browser/loader/keep_alive_url_loader_service_unittest.cc b/content/browser/loader/keep_alive_url_loader_service_unittest.cc
index c74d69a..db0f051 100644
--- a/content/browser/loader/keep_alive_url_loader_service_unittest.cc
+++ b/content/browser/loader/keep_alive_url_loader_service_unittest.cc
@@ -565,6 +565,59 @@
"resource_request.trusted_params must not be set");
}
+TEST_F(KeepAliveURLLoaderServiceTest,
+ LoadRequestWithRetryOptionsWhenFeatureDisabledAndTerminate) {
+ FakeRemoteURLLoaderFactory renderer_loader_factory;
+ MockReceiverURLLoaderClient renderer_loader_client;
+ BindKeepAliveURLLoaderFactory(renderer_loader_factory);
+
+ base::test::ScopedFeatureList overwritten_feature_list;
+ overwritten_feature_list.InitAndDisableFeature(blink::features::kFetchRetry);
+
+ auto resource_request = CreateResourceRequest(GURL(kTestRequestUrl));
+ network::FetchRetryOptions options;
+ options.max_attempts = 1;
+ resource_request.fetch_retry_options = options;
+
+ renderer_loader_factory.CreateLoaderAndStart(
+ resource_request, renderer_loader_client.BindNewPipeAndPassRemote(),
+ /*expect_success=*/false);
+
+ EXPECT_EQ(network_url_loader_factory().NumPending(), 0);
+ EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+ EXPECT_FALSE(renderer_loader_factory.is_remote_url_loader_connected());
+ ExpectMojoBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+}
+
+TEST_F(KeepAliveURLLoaderServiceTest,
+ LoadRequestWithRetryOptionsWithoutOriginTrialAndTerminate) {
+ FakeRemoteURLLoaderFactory renderer_loader_factory;
+ MockReceiverURLLoaderClient renderer_loader_client;
+ BindKeepAliveURLLoaderFactory(renderer_loader_factory);
+
+ auto resource_request = CreateResourceRequest(GURL(kTestRequestUrl));
+ network::FetchRetryOptions options;
+ options.max_attempts = 1;
+ resource_request.fetch_retry_options = options;
+
+ renderer_loader_factory.CreateLoaderAndStart(
+ resource_request, renderer_loader_client.BindNewPipeAndPassRemote(),
+ /*expect_success=*/false);
+
+ EXPECT_EQ(network_url_loader_factory().NumPending(), 0);
+ EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+ EXPECT_FALSE(renderer_loader_factory.is_remote_url_loader_connected());
+ ExpectMojoBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+}
+
TEST_F(KeepAliveURLLoaderServiceTest, LoadRequestAfterPageIsUnloaded) {
FakeRemoteURLLoaderFactory renderer_loader_factory;
MockReceiverURLLoaderClient renderer_loader_client;
@@ -1418,6 +1471,57 @@
}
TEST_F(FetchLaterKeepAliveURLLoaderServiceTest,
+ LoadFetchLaterRequestWithRetryOptionsWhenFeatureDisabledAndTerminate) {
+ FakeRemoteFetchLaterLoaderFactory renderer_loader_factory;
+ BindFetchLaterLoaderFactory(renderer_loader_factory);
+
+ base::test::ScopedFeatureList overwritten_feature_list;
+ overwritten_feature_list.InitAndDisableFeature(blink::features::kFetchRetry);
+
+ auto resource_request =
+ CreateFetchLaterResourceRequest(GURL(kTestRequestUrl));
+ network::FetchRetryOptions options;
+ options.max_attempts = 1;
+ resource_request.fetch_retry_options = options;
+ renderer_loader_factory.CreateLoader(resource_request,
+ /*expect_success=*/false);
+
+ EXPECT_EQ(network_url_loader_factory().NumPending(), 0);
+ EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+ EXPECT_FALSE(
+ renderer_loader_factory.is_remote_fetch_later_loader_connected());
+ ExpectMojoBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+}
+
+TEST_F(FetchLaterKeepAliveURLLoaderServiceTest,
+ LoadFetchLaterRequestWithRetryOptionsWithoutOriginTrialAndTerminate) {
+ FakeRemoteFetchLaterLoaderFactory renderer_loader_factory;
+ BindFetchLaterLoaderFactory(renderer_loader_factory);
+
+ auto resource_request =
+ CreateFetchLaterResourceRequest(GURL(kTestRequestUrl));
+ network::FetchRetryOptions options;
+ options.max_attempts = 1;
+ resource_request.fetch_retry_options = options;
+ renderer_loader_factory.CreateLoader(resource_request,
+ /*expect_success=*/false);
+
+ EXPECT_EQ(network_url_loader_factory().NumPending(), 0);
+ EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+ EXPECT_FALSE(
+ renderer_loader_factory.is_remote_fetch_later_loader_connected());
+ ExpectMojoBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+}
+
+TEST_F(FetchLaterKeepAliveURLLoaderServiceTest,
LoadFetchLaterRequestAndDeferred) {
FakeRemoteFetchLaterLoaderFactory renderer_loader_factory;
BindFetchLaterLoaderFactory(renderer_loader_factory);
Regression Test / PoC
diff --git a/content/browser/loader/keep_alive_url_loader_service_unittest.cc b/content/browser/loader/keep_alive_url_loader_service_unittest.cc
index c74d69a..db0f051 100644
--- a/content/browser/loader/keep_alive_url_loader_service_unittest.cc
+++ b/content/browser/loader/keep_alive_url_loader_service_unittest.cc
@@ -565,6 +565,59 @@
"resource_request.trusted_params must not be set");
}
+TEST_F(KeepAliveURLLoaderServiceTest,
+ LoadRequestWithRetryOptionsWhenFeatureDisabledAndTerminate) {
+ FakeRemoteURLLoaderFactory renderer_loader_factory;
+ MockReceiverURLLoaderClient renderer_loader_client;
+ BindKeepAliveURLLoaderFactory(renderer_loader_factory);
+
+ base::test::ScopedFeatureList overwritten_feature_list;
+ overwritten_feature_list.InitAndDisableFeature(blink::features::kFetchRetry);
+
+ auto resource_request = CreateResourceRequest(GURL(kTestRequestUrl));
+ network::FetchRetryOptions options;
+ options.max_attempts = 1;
+ resource_request.fetch_retry_options = options;
+
+ renderer_loader_factory.CreateLoaderAndStart(
+ resource_request, renderer_loader_client.BindNewPipeAndPassRemote(),
+ /*expect_success=*/false);
+
+ EXPECT_EQ(network_url_loader_factory().NumPending(), 0);
+ EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+ EXPECT_FALSE(renderer_loader_factory.is_remote_url_loader_connected());
+ ExpectMojoBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+}
+
+TEST_F(KeepAliveURLLoaderServiceTest,
+ LoadRequestWithRetryOptionsWithoutOriginTrialAndTerminate) {
+ FakeRemoteURLLoaderFactory renderer_loader_factory;
+ MockReceiverURLLoaderClient renderer_loader_client;
+ BindKeepAliveURLLoaderFactory(renderer_loader_factory);
+
+ auto resource_request = CreateResourceRequest(GURL(kTestRequestUrl));
+ network::FetchRetryOptions options;
+ options.max_attempts = 1;
+ resource_request.fetch_retry_options = options;
+
+ renderer_loader_factory.CreateLoaderAndStart(
+ resource_request, renderer_loader_client.BindNewPipeAndPassRemote(),
+ /*expect_success=*/false);
+
+ EXPECT_EQ(network_url_loader_factory().NumPending(), 0);
+ EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+ EXPECT_FALSE(renderer_loader_factory.is_remote_url_loader_connected());
+ ExpectMojoBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+}
+
TEST_F(KeepAliveURLLoaderServiceTest, LoadRequestAfterPageIsUnloaded) {
FakeRemoteURLLoaderFactory renderer_loader_factory;
MockReceiverURLLoaderClient renderer_loader_client;
@@ -1418,6 +1471,57 @@
}
TEST_F(FetchLaterKeepAliveURLLoaderServiceTest,
+ LoadFetchLaterRequestWithRetryOptionsWhenFeatureDisabledAndTerminate) {
+ FakeRemoteFetchLaterLoaderFactory renderer_loader_factory;
+ BindFetchLaterLoaderFactory(renderer_loader_factory);
+
+ base::test::ScopedFeatureList overwritten_feature_list;
+ overwritten_feature_list.InitAndDisableFeature(blink::features::kFetchRetry);
+
+ auto resource_request =
+ CreateFetchLaterResourceRequest(GURL(kTestRequestUrl));
+ network::FetchRetryOptions options;
+ options.max_attempts = 1;
+ resource_request.fetch_retry_options = options;
+ renderer_loader_factory.CreateLoader(resource_request,
+ /*expect_success=*/false);
+
+ EXPECT_EQ(network_url_loader_factory().NumPending(), 0);
+ EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+ EXPECT_FALSE(
+ renderer_loader_factory.is_remote_fetch_later_loader_connected());
+ ExpectMojoBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+}
+
+TEST_F(FetchLaterKeepAliveURLLoaderServiceTest,
+ LoadFetchLaterRequestWithRetryOptionsWithoutOriginTrialAndTerminate) {
+ FakeRemoteFetchLaterLoaderFactory renderer_loader_factory;
+ BindFetchLaterLoaderFactory(renderer_loader_factory);
+
+ auto resource_request =
+ CreateFetchLaterResourceRequest(GURL(kTestRequestUrl));
+ network::FetchRetryOptions options;
+ options.max_attempts = 1;
+ resource_request.fetch_retry_options = options;
+ renderer_loader_factory.CreateLoader(resource_request,
+ /*expect_success=*/false);
+
+ EXPECT_EQ(network_url_loader_factory().NumPending(), 0);
+ EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+ EXPECT_FALSE(
+ renderer_loader_factory.is_remote_fetch_later_loader_connected());
+ ExpectMojoBadMessage(
+ "Unexpected `resource_request` in "
+ "KeepAliveURLLoaderFactoriesBase::CreateLoaderAndStart(): "
+ "resource_request.fetch_retry_options must not be set when "
+ "FetchRetry is disabled");
+}
+
+TEST_F(FetchLaterKeepAliveURLLoaderServiceTest,
LoadFetchLaterRequestAndDeferred) {
FakeRemoteFetchLaterLoaderFactory renderer_loader_factory;
BindFetchLaterLoaderFactory(renderer_loader_factory);
Original Bug Report
Bypass of FetchRetry kill-switch and Origin Trial in KeepAliveURLLoader
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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The browser-side implementation of KeepAliveURLLoader fails to validate the FetchRetry feature gate when processing retry options in a network request. This allows a compromised renderer to unilaterally enable experimental retry logic, bypassing intended security kill-switches and Origin Trial restrictions. This logic flaw extends the reachable attack surface and object lifetimes within the browser process.
Affected files:
content/browser/loader/keep_alive_url_loader_service.cccontent/browser/loader/keep_alive_url_loader.cc
Estimated timestamp from git blame: 2025-06-12
Summary
The KeepAliveURLLoader in the browser process does not perform feature-gate or Origin Trial validation for the fetch_retry_options field in network::ResourceRequest. An attacker who has compromised a renderer process can bypass the FetchRetry feature kill-switch and Origin Trial restrictions, exposing experimental retry logic and extending the lifetime of browser-side loader objects far beyond the standard limits.
Root Cause Analysis
In the renderer process, the FetchRetry feature is correctly guarded by RuntimeEnabledFeatures::FetchRetryEnabled() in third_party/blink/renderer/core/fetch/request.cc. This ensures that only authorized origins or sessions where the feature is enabled via Finch can use the retry functionality.
However, the browser process lacks a corresponding check. When a keepalive request is initiated, content/browser/loader/keep_alive_url_loader.cc evaluates eligibility for retry in the IsEligibleForRetry method (lines 931-1002). The current implementation only checks if the fetch_retry_options field is populated in the ResourceRequest:
// content/browser/loader/keep_alive_url_loader.cc:931
bool KeepAliveURLLoader::IsEligibleForRetry(
std::optional<network::URLLoaderCompletionStatus> completion_status) const {
auto retry_options = resource_request_.fetch_retry_options;
if (!retry_options.has_value()) {
// The fetch must opt-in to retry.
return false;
}
// ... [Missing check for base::FeatureList::IsEnabled(blink::features::kFetchRetry)]
By manually constructing a Mojo message from a compromised renderer, an attacker can populate the fetch_retry_options field even if the feature is disabled. The browser will then proceed to schedule retries, honoring experimental parameters such as max_attempts and retry_after_unload.
Impact
- Bypass of Security Controls: Bypasses the
kFetchRetrykill-switch and Origin Trial enforcement. This allows unauthorized origins to use experimental browser-side logic. - Extended Object Lifetime: A standard keepalive request is typically destroyed 30 seconds after a renderer disconnects. By enabling unauthorized retries with a large
max_age(default 24 hours), an attacker can cause the browser to maintain state and perform automated network activity long after the initiating renderer has been closed. - Increased Attack Surface: Exposes the browser-side retry state machine and scheduling logic to all origins, regardless of feature status.
Potential Reproduction Steps
- Launch Chrome with
--disable-features=FetchRetryto simulate a kill-switch activation. - From a compromised renderer, obtain the
network.mojom.URLLoaderFactoryfor keepalive requests. - Call
CreateLoaderAndStartwith anetwork.mojom.ResourceRequestwherekeepalive = trueandfetch_retry_optionsis populated (e.g.,max_attempts = 10,retry_after_unload = true). - Force a network failure for the initial request (e.g., a connection reset).
- Observe that the browser schedules a retry in
KeepAliveURLLoader::MaybeScheduleRetry, ignoring the disabled feature state.
Suggested Fix
Add an explicit feature check in content/browser/loader/keep_alive_url_loader.cc within the IsEligibleForRetry method:
if (!base::FeatureList::IsEnabled(blink::features::kFetchRetry)) {
return false;
}
Alternatively, KeepAliveURLLoaderService should clear the fetch_retry_options field in CreateKeepAliveURLLoader if the feature is not enabled.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.