Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper initialization in Network
DescriptionImproper initialization in Network
ComponentNetwork
Bug ClassLogic Error
Tracker511819962
Fix commit63f69d21bd32 (chromium/src) +69/-7
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • content/browser/loader/keep_alive_url_loader.cc
  • content/browser/loader/keep_alive_url_loader.h
  • content/browser/loader/keep_alive_url_loader_service_unittest.cc
From 63f69d21bd323c4071aa4c42620a39064140ab19 Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <[email protected]>
Date: Mon, 27 Jul 2026 06:32:39 -0700
Subject: [PATCH] Reset KeepAliveURLLoader state on retry

KeepAliveURLLoader did not clear its intermediate redirect state and
other per-attempt variables (last_url_, stored_url_load_) when starting
a retry attempt. This led to stale redirect data from the failed attempt
being replayed to the renderer, causing response.url spoofing and
bypassing Content Security Policy path-matching rules.

Fix this by explicitly resetting stored_url_load_ and last_url_ inside
AttemptRetryIfAllowed().

Fixed: 511819962
Change-Id: Ib35a4ef39380c297037b6b202250dee9d5d4d5fe
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8135338
Commit-Queue: Andrew Paseltiner <[email protected]>
Reviewed-by: Kenichi Ishibashi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1668586}
---

diff --git a/content/browser/loader/keep_alive_url_loader.cc b/content/browser/loader/keep_alive_url_loader.cc
index 4264fd7..f050636 100644
--- a/content/browser/loader/keep_alive_url_loader.cc
+++ b/content/browser/loader/keep_alive_url_loader.cc
@@ -1005,12 +1005,8 @@
                        base::Unretained(this)));
   }
 
-  // Update the retry-tracking states. Note that there's no need to reset any
-  // of the actual request-related state, since the retry is attempted from the
-  // last request attempt, and no state has been updated in response of the
-  // failed result yet. All states relating to previous attempts (e.g. stored
-  // loads storing previous redirects) only contain results from successful
-  // redirects/responses so there's no need to reset.
+  // Update the retry-tracking states. The per-attempt request state will be
+  // reset when the retry actually starts in `AttemptRetryIfAllowed()`.
   retry_count_++;
   CHECK_LE(retry_count_, GetMaxAttemptsForRetry());
   retry_state_ = RetryState::kRetryScheduled;
@@ -1053,8 +1049,13 @@
   devtools_request_id_ = base::UnguessableToken::Create().ToString();
 
   // Retry using the original request, even if the failure happens after
-  // redirects.
+  // redirects. Any per-attempt state derived from the failed attempt's redirect
+  // chain must be reset so that only results from the retried attempt are
+  // forwarded to the renderer. Note that `redirect_limit_` and
+  // `did_encounter_redirect_` are intentionally tracked across retries.
   resource_request_ = original_resource_request_;
+  stored_url_load_ = std::make_unique<StoredURLLoad>();
+  last_url_ = initial_url_;
   if (features::kAddRetryHeader.Get()) {
     // Add retry information in the header.
     resource_request_.headers.SetHeader(kRetryAttemptsHeader,
diff --git a/content/browser/loader/keep_alive_url_loader.h b/content/browser/loader/keep_alive_url_loader.h
index cdc171a5..fefa8380 100644
--- a/content/browser/loader/keep_alive_url_loader.h
+++ b/content/browser/loader/keep_alive_url_loader.h
@@ -346,6 +346,8 @@
   FRIEND_TEST_ALL_PREFIXES(KeepAliveURLLoaderServiceRetryTest,
                            ReceivedResponseWillNotBeRetried);
   FRIEND_TEST_ALL_PREFIXES(KeepAliveURLLoaderServiceRetryTest,
+                           RetryAfterRedirectResetsPerAttemptState);
+  FRIEND_TEST_ALL_PREFIXES(KeepAliveURLLoaderServiceRetryTest,
                            ExceededRedirectLimitWillNotBeRetried);
   FRIEND_TEST_ALL_PREFIXES(KeepAliveURLLoaderServiceRetryTest,
                            SelfDeletionOnMaxAge);
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 634c9f2..c6528d5 100644
--- a/content/browser/loader/keep_alive_url_loader_service_unittest.cc
+++ b/content/browser/loader/keep_alive_url_loader_service_unittest.cc
@@ -13,6 +13,7 @@
 #include "base/strings/strcat.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
+#include "base/test/run_until.h"
 #include "base/test/scoped_feature_list.h"
 #include "content/browser/storage_partition_impl.h"
 #include "content/public/browser/browser_context.h"
@@ -1881,6 +1882,64 @@
   EXPECT_TRUE(loader->IsForwardURLLoadStarted());
 }
 
+// Regression test for crbug.com/511819962: Test that retrying a request after a
+// redirect resets the per-attempt request state, so that the retried response
+// is forwarded with only the redirects from the retried attempt.
+TEST_F(KeepAliveURLLoaderServiceRetryTest,
+       RetryAfterRedirectResetsPerAttemptState) {
+  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;
+
+  // Loads keepalive request:
+  renderer_loader_factory.CreateLoaderAndStart(
+      resource_request, renderer_loader_client.BindNewPipeAndPassRemote());
+  ASSERT_EQ(network_url_loader_factory().NumPending(), 1);
+  ASSERT_EQ(loader_service().NumLoadersForTesting(), 1u);
+
+  base::WeakPtr<KeepAliveURLLoader> loader =
+      loader_service().GetLoaderWithRequestIdForTesting(
+          FakeRemoteURLLoaderFactory::kRequestId);
+
+  // Simulate the first attempt receiving a redirect, then failing with a
+  // retriable error before the redirected request completes.
+  loader->EndReceiveRedirect(CreateRedirectInfo(GURL(kTestRedirectRequestUrl)),
+                             CreateResponseHead({{kTestResponseHeaderName,
+                                                  kTestResponseHeaderValue}}));
+  loader->OnComplete(
+      network::URLLoaderCompletionStatus(net::ERR_NAME_NOT_RESOLVED));
+  ASSERT_TRUE(loader->IsAttemptingRetry(/*include_failed_retry=*/false));
+  ASSERT_FALSE(loader->IsForwardURLLoadStarted());
+
+  // Fast-forward so the scheduled retry runs and starts a new request from the
+  // original URL. The first attempt's loader has been reset, so only the
+  // retried request remains pending.
+  task_environment()->FastForwardBy(kMinRetryDeltaForTesting);
+  ASSERT_TRUE(loader.get());
+  ASSERT_EQ(network_url_loader_factory().NumPending(), 1);
+  EXPECT_EQ(GetLastPendingRequest()->request.url, GURL(kTestRequestUrl));
+
+  // Simulate the retried request receiving a response without redirecting.
+  // The renderer should only see the response from the retried attempt and not
+  // the redirect from the failed first attempt.
+  EXPECT_CALL(renderer_loader_client, OnReceiveRedirect(_, _)).Times(0);
+  EXPECT_CALL(renderer_loader_client,
+              OnReceiveResponse(ResponseHasHeader(kTestResponseHeaderName,
+                                                  kTestResponseHeaderValue),
+                                _, Eq(std::nullopt)))
+      .Times(1);
+  loader->OnReceiveResponse(
+      CreateResponseHead({{kTestResponseHeaderName, kTestResponseHeaderValue}}),
+      /*body=*/{}, std::nullopt);
+  ASSERT_TRUE(base::test::RunUntil(
+      [&]() { return loader && loader->IsForwardURLLoadStarted(); }));
+}
+
 // Test that hitting the redirect limit won't trigger a retry.
 TEST_F(KeepAliveURLLoaderServiceRetryTest,
        ExceededRedirectLimitWillNotBeRetried) {
Loading diff…

Regression Test / PoC

shipped with the fix
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 634c9f2..c6528d5 100644
--- a/content/browser/loader/keep_alive_url_loader_service_unittest.cc
+++ b/content/browser/loader/keep_alive_url_loader_service_unittest.cc
@@ -13,6 +13,7 @@
 #include "base/strings/strcat.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
+#include "base/test/run_until.h"
 #include "base/test/scoped_feature_list.h"
 #include "content/browser/storage_partition_impl.h"
 #include "content/public/browser/browser_context.h"
@@ -1881,6 +1882,64 @@
   EXPECT_TRUE(loader->IsForwardURLLoadStarted());
 }
 
+// Regression test for crbug.com/511819962: Test that retrying a request after a
+// redirect resets the per-attempt request state, so that the retried response
+// is forwarded with only the redirects from the retried attempt.
+TEST_F(KeepAliveURLLoaderServiceRetryTest,
+       RetryAfterRedirectResetsPerAttemptState) {
+  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;
+
+  // Loads keepalive request:
+  renderer_loader_factory.CreateLoaderAndStart(
+      resource_request, renderer_loader_client.BindNewPipeAndPassRemote());
+  ASSERT_EQ(network_url_loader_factory().NumPending(), 1);
+  ASSERT_EQ(loader_service().NumLoadersForTesting(), 1u);
+
+  base::WeakPtr<KeepAliveURLLoader> loader =
+      loader_service().GetLoaderWithRequestIdForTesting(
+          FakeRemoteURLLoaderFactory::kRequestId);
+
+  // Simulate the first attempt receiving a redirect, then failing with a
+  // retriable error before the redirected request completes.
+  loader->EndReceiveRedirect(CreateRedirectInfo(GURL(kTestRedirectRequestUrl)),
+                             CreateResponseHead({{kTestResponseHeaderName,
+                                                  kTestResponseHeaderValue}}));
+  loader->OnComplete(
+      network::URLLoaderCompletionStatus(net::ERR_NAME_NOT_RESOLVED));
+  ASSERT_TRUE(loader->IsAttemptingRetry(/*include_failed_retry=*/false));
+  ASSERT_FALSE(loader->IsForwardURLLoadStarted());
+
+  // Fast-forward so the scheduled retry runs and starts a new request from the
+  // original URL. The first attempt's loader has been reset, so only the
+  // retried request remains pending.
+  task_environment()->FastForwardBy(kMinRetryDeltaForTesting);
+  ASSERT_TRUE(loader.get());
+  ASSERT_EQ(network_url_loader_factory().NumPending(), 1);
+  EXPECT_EQ(GetLastPendingRequest()->request.url, GURL(kTestRequestUrl));
+
+  // Simulate the retried request receiving a response without redirecting.
+  // The renderer should only see the response from the retried attempt and not
+  // the redirect from the failed first attempt.
+  EXPECT_CALL(renderer_loader_client, OnReceiveRedirect(_, _)).Times(0);
+  EXPECT_CALL(renderer_loader_client,
+              OnReceiveResponse(ResponseHasHeader(kTestResponseHeaderName,
+                                                  kTestResponseHeaderValue),
+                                _, Eq(std::nullopt)))
+      .Times(1);
+  loader->OnReceiveResponse(
+      CreateResponseHead({{kTestResponseHeaderName, kTestResponseHeaderValue}}),
+      /*body=*/{}, std::nullopt);
+  ASSERT_TRUE(base::test::RunUntil(
+      [&]() { return loader && loader->IsForwardURLLoadStarted(); }));
+}
+
 // Test that hitting the redirect limit won't trigger a retry.
 TEST_F(KeepAliveURLLoaderServiceRetryTest,
        ExceededRedirectLimitWillNotBeRetried) {
Loading diff…

Original Bug Report

reported by [email protected]

Stale state in KeepAliveURLLoader retry leads to CSP bypass and response.url spoofing

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: KeepAliveURLLoader fails to clear its internal redirect state when retrying a request that failed after a redirect. This causes stale redirect data to be replayed to the renderer and used in browser-side security checks. Consequently, this can potentially lead to response.url spoofing in JavaScript, bypasses of path-based Content Security Policies (CSP), and forged Attribution Reporting registrations.

Affected files:

  • content/browser/loader/keep_alive_url_loader.cc

Estimated timestamp from git blame: 2026-02-10

Summary

There is a potential logic flaw in KeepAliveURLLoader where internal state related to redirects is not cleared when a request is retried. If a network request encounters a redirect and subsequently fails with a retriable error (e.g., net::ERR_NAME_NOT_RESOLVED), the loader attempts a retry. However, it retains the stale redirect history, which is then replayed to the renderer and used in browser-side checks, leading to several security impacts including response.url spoofing and CSP path-matching bypasses.

Note: The FetchRetry feature is currently behind an experimental Origin Trial, but a compromised or malicious renderer opting into the trial can trigger this browser-process logic.

Root Cause Analysis

When a request fails, KeepAliveURLLoader::MaybeScheduleRetry and subsequently AttemptRetryIfAllowed (in content/browser/loader/keep_alive_url_loader.cc) reset the request back to its original state using resource_request_ = original_resource_request_.

However, several redirect-related states are not cleared:

  1. stored_url_load_->redirects: This queue stores intermediate redirects. It is appended to in EndReceiveRedirect. During a retry, it is never emptied, meaning redirects from the failed attempt persist.
  2. last_url_: This variable tracks the current URL in the redirect chain. It is not reset to initial_url_ on retry.
  3. attribution_request_helper_: The helper used for Attribution Reporting retains its updated reporting_url_ from the failed redirect.

Security Impacts

1. response.url Spoofing When the retried request successfully completes, ForwardURLLoad() is called to send the response back to the renderer. Because the stored_url_load_->redirects queue is not empty, ForwardURLLoad() first replays the stale redirect to the renderer (forwarding_client_->OnReceiveRedirect). The renderer appends this spoofed URL to its internal url_list_. When the actual 200 OK response arrives, the Fetch API resolves response.url to the last entry in the url_list_ (the stale redirect target), while serving the attacker-controlled response body.

2. CSP Path-Matching Bypass During the retry, if the new attempt encounters another redirect, KeepAliveURLLoader::WillFollowRedirect evaluates CSP. It determines if a redirect has already occurred by evaluating last_url_ != initial_url_. Because last_url_ retains the stale value from the first attempt, this evaluates to true. Per CSP rules, if a redirect has occurred, path-matching is relaxed to origin-only matching (has_followed_redirect = true in SourceAllowPath). This potentially bypasses connect-src path restrictions.

3. Forged Attribution Reporting The KeepAliveAttributionRequestHelper processes the successful response’s headers using its internal reporting_url_. Because this was updated during the first failed redirect and not reset, it incorrectly attributes the response to the forged target URL of the stale redirect.

Potential Reproduction Steps

These are suggested steps to trigger the vulnerability from an attacker-controlled origin:

  1. An attacker sets up a server (attacker.com) that has the FetchRetry Origin Trial enabled.
  2. The attacker executes JavaScript to trigger a fetch: fetch('https://attacker.com/start', { keepalive: true, retryOptions: { maxAttempts: 2 } });
  3. The attacker’s server responds to the first request with an HTTP 302 Redirect to a target URL (e.g., https://nonexistent.victim.example/).
  4. The browser follows the redirect, updates last_url_, and queues the redirect internally.
  5. The connection to the nonexistent domain fails with ERR_NAME_NOT_RESOLVED.
  6. The browser initiates a retry because the error is retriable.
  7. On the second attempt, the attacker’s server responds with an HTTP 200 OK and an attacker-controlled payload.
  8. The browser replays the stale redirect to the renderer followed by the 200 OK response. The JavaScript promise resolves with response.url incorrectly showing https://nonexistent.victim.example/.

Suggested Fix

When a retry is initiated (e.g., inside KeepAliveURLLoader::AttemptRetryIfAllowed or MaybeScheduleRetry), explicitly reset all state associated with the previous failed attempt. This includes:

  • Clearing the stored_url_load_->redirects queue.
  • Resetting last_url_ to initial_url_.
  • Resetting did_encounter_redirect_ to false (or carefully reconsidering if it should remain cumulative depending on the intended design).
  • Resetting or re-initializing attribution_request_helper_.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


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.

View on issue tracker
Links in the report