Low watch CVSS 4.3 chrome Policy Bypass 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in Downloads
DescriptionPolicy bypass in Downloads
ComponentDownloads
Bug ClassPolicy Bypass
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.
Tracker475265304
Fix commit69fd5caaf52d (chromium/src) +107/-10
CWECWE-693
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedLuan Herrera (@lbherrera_)
Reported2026-01-13 (per vendor advisory)
Fix landed2026-03-09 (public git)
Disclosed2026-04-07
Patch gap29 days — fix public in source before the advisory

Background

`NetErrorAutoReloader`
A WebContentsObserver that, after a main-frame network error commits an error page, periodically reloads the page until a real page commits.
Navigation commit cancellation
A started navigation can finish without committing for several reasons: the auto-reloader suppressing a redundant identical error page, a 204 No Content, a download, or the user/site cancelling it.
`pending_navigations_`
The container tracking in-flight navigations; changed in this patch from a std::set of handles to a std::map from handle to an IsSuppressedErrorPage flag.
`IsSuppressedErrorPage`
A new base::StrongAlias<...> boolean recording whether a given navigation was one the auto-reloader itself cancelled because it was a duplicate error page.

Root Cause Analysis

In NetErrorAutoReloader::DidFinishNavigation, when the last pending navigation finished without committing, the old code unconditionally set is_auto_reload_in_progress_ = false and then called ScheduleNextAutoReload(), regardless of why that navigation was cancelled. As the commit message states, it “does not check the reason that a commit failed,” so a commit cancelled because it turned into a download (or a 204, or a user-initiated cancel) was treated the same as the auto-reloader suppressing a redundant error page, and the reload timer kept firing.

The fix records, at navigation start, whether each handle corresponds to a suppressed-redundant-error-page load (pending_navigations_.emplace(handle, IsSuppressedErrorPage(false))), sets that flag to true in ShouldSuppressErrorPage when the throttle actually suppresses an identical error page, and in DidFinishNavigation only calls ScheduleNextAutoReload() when the cancelled navigation was such a suppressed error page (if (is_suppressed_error_page)). Thus a cancellation caused by a download no longer re-arms the auto-reload timer.

The added regression test AutoReloadDownloadStopsAutoReload confirms that an auto-reload resulting in a download leaves the error page showing with no pending reload.

Key insight
The auto-reloader rescheduled a reload on any uncommitted navigation instead of only on ones it had itself cancelled as redundant error pages; the fix tags each pending navigation with an IsSuppressedErrorPage flag and reschedules only when that flag is set.

Attack Path

  1. Force a main-frame network error Content loads a URL that fails to commit (e.g. ERR_CONNECTION_RESET), committing an auto-reloadable error page and starting the auto-reload timer.
  2. Auto-reload turns into a download On a scheduled reload, the server responds in a way that becomes a download (or a 204), so the navigation is cancelled rather than committed.
  3. Timer keeps firing (pre-patch) DidFinishNavigation saw an uncommitted last navigation and called ScheduleNextAutoReload() without checking the cancel reason, so the page kept auto-reloading despite the download/cancellation.
  4. Policy bypass (inferred) The persistent auto-reload repeatedly re-requests the URL, which per the advisory enables a policy bypass in Downloads; the specific download-policy code is not present in this diff, so the exact bypass mechanism is (inferred) from the advisory and the download-focused regression test.

Impact Assessment

Per the advisory this is a low-severity policy bypass in Downloads: overly aggressive auto-reload after a cancelled/download navigation keeps re-driving navigations in the browser process that the auto-reloader should have abandoned. It is reachable by ordinary web content that can cause a main-frame network error followed by a download, with no special flag, extension, or compromised renderer required; the patch shows only the reload-scheduling behavior change, so any downstream download-policy consequence beyond continued reloading is (inferred).

Changed Functions

FunctionChangeNotes
if
components/error_page/content/browser/net_error_auto_reloader.cc
modified
TestShellDownloadManagerDelegate
components/error_page/content/browser/net_error_auto_reloader_browsertest.cc
modified

Files Changed

  • components/error_page/content/browser/net_error_auto_reloader.cc
  • components/error_page/content/browser/net_error_auto_reloader.h
  • components/error_page/content/browser/net_error_auto_reloader_browsertest.cc

Audit Directions

  • Reason-blind cancellation handling
    Review observers/throttles that react to uncommitted navigations; check whether they distinguish the cancel reason (download, 204, user abort, same-document) before taking follow-up action like retrying or rescheduling.
  • State keyed only by handle identity
    Where per-navigation state is a bare set of handles, look for places that need per-navigation attributes (why it started/ended) and may conflate unrelated navigations.
  • Auto-retry loops
    Audit any auto-reload/auto-retry logic for conditions that let it persist across navigations it did not initiate, which can re-trigger side effects such as downloads.
From 69fd5caaf52d636159a13ee893e63de0fd249495 Mon Sep 17 00:00:00 2001
From: Matt Menke <mmenke@chromium.org>
Date: Mon, 09 Mar 2026 12:05:58 -0700
Subject: [PATCH] Make NetErrorAutoReloader less aggressive.

On certain main frame network errors, NetErrorAutoReloader will
periodically reload the page, and keep on doing so until a new page is
committed, checking on every failed commit if the last commit was an
error page that needed reloading, and starting a reload timer if so,
as well as cancelling commits of an identical new error page. It does
not check the reason that a commit failed, so a commit being cancelled
because of a 204, or a download, will not stop the reloaded from
trying to reload the page.

This CL instead only starts the autoreload time on failed commits if
that failed commit was due to the AutoReloader itself cancelling the
previous commit, due to it being the same error page.

So now, e.g., cancelling an auto reload, triggering a download (which
looks like a cancelled navigation), or starting a new load and then
cancelling it before commit will all stop the auto reload timer.
This is a bit more user friendly, though going offline and then online
again will restart the reload timer (as will another navigation
resulting in a network error, whether it's the same error or a new
one).

Fixed: 475265304
Change-Id: If12a2256c33bf5f91c7ad544c0ad3e2f682df855
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7629083
Reviewed-by: Adam Rice <ricea@chromium.org>
Commit-Queue: mmenke <mmenke@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1596504}
---

diff --git a/components/error_page/content/browser/net_error_auto_reloader.cc b/components/error_page/content/browser/net_error_auto_reloader.cc
index 5eb7c6c9..5808aef 100644
--- a/components/error_page/content/browser/net_error_auto_reloader.cc
+++ b/components/error_page/content/browser/net_error_auto_reloader.cc
@@ -6,6 +6,7 @@
 
 #include <algorithm>
 #include <array>
+#include <map>
 
 #include "base/functional/callback.h"
 #include "base/logging.h"
@@ -164,7 +165,7 @@
 
   // Suppress automatic reload as long as any navigations are pending.
   PauseAutoReloadTimerIfRunning();
-  pending_navigations_.insert(handle);
+  pending_navigations_.emplace(handle, IsSuppressedErrorPage(false));
 }
 
 void NetErrorAutoReloader::DidFinishNavigation(
@@ -173,7 +174,22 @@
     return;
   }
 
-  pending_navigations_.erase(handle);
+  auto pending_navigation = pending_navigations_.find(handle);
+  bool is_suppressed_error_page = false;
+  // Per comments above MaybeCreateAndAddNavigationThrottle(), the
+  // NetErrorAutoReloader is only created for a WebContents as needed, when
+  // creating throttles, so it never receives the navigation start event for a
+  // tab's initial navigation, and as a result, the first navigation is never
+  // added to `pending_navigations_`. This isn't a problem, as the
+  // NetErrorAutoReloader doesn't do anything until after an error page commits,
+  // but it does mean that we have to handle the case where the committed
+  // navigation isn't listed in `pending_navigations_`.
+  if (pending_navigation != pending_navigations_.end()) {
+    is_suppressed_error_page =
+        pending_navigation->second == IsSuppressedErrorPage(true);
+    pending_navigations_.erase(pending_navigation);
+  }
+
   if (!handle->HasCommitted()) {
     // This navigation was cancelled and not committed. If there are still other
     // pending navigations, or we aren't sitting on a error page which allows
@@ -182,11 +198,14 @@
       return;
     }
 
-    // The last pending navigation was just cancelled and we're sitting on an
-    // error page which allows auto-reload. Schedule the next auto-reload
-    // attempt.
+    // There are no pending navigations, so there's no auto-reload in progress.
     is_auto_reload_in_progress_ = false;
-    ScheduleNextAutoReload();
+
+    // The last pending navigation was just cancelled due to resulting in an
+    // identical error page as before. Schedule the next auto-reload attempt.
+    if (is_suppressed_error_page) {
+      ScheduleNextAutoReload();
+    }
     return;
   }
 
@@ -334,6 +353,8 @@
 
 bool NetErrorAutoReloader::ShouldSuppressErrorPage(
     content::NavigationHandle* handle) {
+  DCHECK(pending_navigations_.contains(handle));
+
   // We already verified these conditions when the throttle was created, but now
   // that the throttle is about to fail its navigation, we double-check in case
   // another navigation has committed in the interim.
@@ -343,6 +364,7 @@
     return false;
   }
 
+  pending_navigations_[handle] = IsSuppressedErrorPage(true);
   return true;
 }
 
diff --git a/components/error_page/content/browser/net_error_auto_reloader.h b/components/error_page/content/browser/net_error_auto_reloader.h
index 8bced091..9057190 100644
--- a/components/error_page/content/browser/net_error_auto_reloader.h
+++ b/components/error_page/content/browser/net_error_auto_reloader.h
@@ -7,14 +7,15 @@
 
 #include <stddef.h>
 
+#include <map>
 #include <memory>
 #include <optional>
-#include <set>
 
 #include "base/memory/raw_ptr.h"
 #include "base/memory/weak_ptr.h"
 #include "base/time/time.h"
 #include "base/timer/timer.h"
+#include "base/types/strong_alias.h"
 #include "content/public/browser/web_contents_observer.h"
 #include "content/public/browser/web_contents_user_data.h"
 #include "net/base/net_errors.h"
@@ -46,7 +47,7 @@
   NetErrorAutoReloader& operator=(const NetErrorAutoReloader&) = delete;
   ~NetErrorAutoReloader() override;
 
-  // Maybe installs a throttle for the given navigation, lazily initializing the
+  // Maybe install a throttle for the given navigation, lazily initializing the
   // appropriate WebContents' NetErrorAutoReloader instance if necessary. For
   // embedders wanting to use NetErrorAutoReload's behavior, it's sufficient to
   // call this from ContentBrowserClient::CreateThrottlesForNavigation for each
@@ -103,9 +104,15 @@
     net::Error error;
   };
 
+  // True if a NavigationHandle corresponds to a load that was suppressed due to
+  // being a redundant error page load.
+  using IsSuppressedErrorPage =
+      base::StrongAlias<struct IsSuppressedErrorPageTag, bool>;
+
   raw_ptr<network::NetworkConnectionTracker> connection_tracker_;
   bool is_online_ = true;
-  std::set<raw_ptr<content::NavigationHandle, SetExperimental>>
+  std::map<raw_ptr<content::NavigationHandle, SetExperimental>,
+           IsSuppressedErrorPage>
       pending_navigations_;
   std::optional<base::OneShotTimer> next_reload_timer_;
   std::optional<ErrorPageInfo> current_reloadable_error_page_info_;
diff --git a/components/error_page/content/browser/net_error_auto_reloader_browsertest.cc b/components/error_page/content/browser/net_error_auto_reloader_browsertest.cc
index 0e777fb3..d883833d 100644
--- a/components/error_page/content/browser/net_error_auto_reloader_browsertest.cc
+++ b/components/error_page/content/browser/net_error_auto_reloader_browsertest.cc
@@ -10,6 +10,9 @@
 
 #include "base/memory/raw_ptr.h"
 #include "base/test/bind.h"
+#include "content/public/browser/browser_context.h"
+#include "content/public/browser/download_manager.h"
+#include "content/public/browser/download_manager_delegate.h"
 #include "content/public/browser/navigation_controller.h"
 #include "content/public/browser/navigation_throttle_registry.h"
 #include "content/public/browser/web_contents.h"
@@ -479,9 +482,74 @@
   EXPECT_EQ(std::nullopt, GetCurrentAutoReloadDelay());
 
   // Now cancel the deferred navigation and observe that auto-reload for the
-  // error page is rescheduled.
+  // error page is cancelled.
   deferrer.CancelAndWaitForNavigationToFinish();
+  EXPECT_EQ(std::nullopt, GetCurrentAutoReloadDelay());
+}
+
+// A DownloadManagerDelegate that claims it handles all downloads, while
+// silently dropping them instead. This prevents any save dialog, or trying to
+// save anything to disk.
+class TestShellDownloadManagerDelegate
+    : public content::DownloadManagerDelegate {
+ public:
+  TestShellDownloadManagerDelegate() = default;
+  ~TestShellDownloadManagerDelegate() override = default;
+
+  // DownloadManagerDelegate:
+  bool InterceptDownloadIfApplicable(
+      const GURL& url,
+      const std::string& user_agent,
+      const std::string& content_disposition,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/error_page/content/browser/net_error_auto_reloader_browsertest.cc b/components/error_page/content/browser/net_error_auto_reloader_browsertest.cc
index 0e777fb3..d883833d 100644
--- a/components/error_page/content/browser/net_error_auto_reloader_browsertest.cc
+++ b/components/error_page/content/browser/net_error_auto_reloader_browsertest.cc
@@ -10,6 +10,9 @@
 
 #include "base/memory/raw_ptr.h"
 #include "base/test/bind.h"
+#include "content/public/browser/browser_context.h"
+#include "content/public/browser/download_manager.h"
+#include "content/public/browser/download_manager_delegate.h"
 #include "content/public/browser/navigation_controller.h"
 #include "content/public/browser/navigation_throttle_registry.h"
 #include "content/public/browser/web_contents.h"
@@ -479,9 +482,74 @@
   EXPECT_EQ(std::nullopt, GetCurrentAutoReloadDelay());
 
   // Now cancel the deferred navigation and observe that auto-reload for the
-  // error page is rescheduled.
+  // error page is cancelled.
   deferrer.CancelAndWaitForNavigationToFinish();
+  EXPECT_EQ(std::nullopt, GetCurrentAutoReloadDelay());
+}
+
+// A DownloadManagerDelegate that claims it handles all downloads, while
+// silently dropping them instead. This prevents any save dialog, or trying to
+// save anything to disk.
+class TestShellDownloadManagerDelegate
+    : public content::DownloadManagerDelegate {
+ public:
+  TestShellDownloadManagerDelegate() = default;
+  ~TestShellDownloadManagerDelegate() override = default;
+
+  // DownloadManagerDelegate:
+  bool InterceptDownloadIfApplicable(
+      const GURL& url,
+      const std::string& user_agent,
+      const std::string& content_disposition,
+      const std::string& mime_type,
+      const std::string& request_origin,
+      int64_t content_length,
+      bool is_transient,
+      bool is_content_initiated,
+      content::WebContents* web_contents) override {
+    run_loop_.Quit();
+    return true;
+  }
+
+  void WaitForDownload() { run_loop_.Run(); }
+
+ private:
+  base::RunLoop run_loop_;
+};
+
+// When an autoreload results in a download, and thus cancels the navigation,
+// auto-reloading is stopped.
+IN_PROC_BROWSER_TEST_F(NetErrorAutoReloaderBrowserTest,
+                       AutoReloadDownloadStopsAutoReload) {
+  GURL download_url = embedded_test_server()->GetURL("/download-test1.lib");
+
+  // Force an error to initiate auto-reload.
+  auto interceptor = std::make_unique<NetErrorUrlInterceptor>(
+      download_url, net::ERR_CONNECTION_RESET);
+
+  EXPECT_FALSE(NavigateMainFrame(download_url));
   EXPECT_EQ(GetDelayForReloadCount(0), GetCurrentAutoReloadDelay());
+  interceptor.reset();
+
+  TestShellDownloadManagerDelegate test_delegate;
+  content::DownloadManager* manager =
+      shell()->web_contents()->GetBrowserContext()->GetDownloadManager();
+  manager->GetDelegate()->Shutdown();
+  manager->SetDelegate(&test_delegate);
+
+  content::TestNavigationManager navigation(web_contents(), download_url);
+  ForceScheduledAutoReloadNow();
+  // This is considered a successful navigation by the WebContents, despite the
+  // navigation being cancelled, from the perspective of NavigationObservers.
+  EXPECT_TRUE(navigation.WaitForNavigationFinished());
+  test_delegate.WaitForDownload();
+
+  // The error page should still be showing.
+  EXPECT_TRUE(web_contents()->GetPrimaryMainFrame()->IsErrorDocument());
+  // There should be no new auto reload pending.
+  EXPECT_EQ(std::nullopt, GetCurrentAutoReloadDelay());
+
+  manager->SetDelegate(nullptr);
 }
 
 // An error page while offline does not trigger auto-reload.
Loading diff…

Original Bug Report

reported by he...@gmail.com

NetError's page AutoReloader leads to multi-download blocker bypass

VULNERABILITY DETAILS

Chrome’s NetErrorAutoReloader automatically retries failed navigations (including ERR_TOO_MANY_REDIRECTS) after a delay. When a navigation exceeds the 20-redirect limit and fails with ERR_TOO_MANY_REDIRECTS, the auto-reloader retries it after a 1-second delay. This retry allows an attacker to trigger downloads without proper user interaction validation, effectively bypassing the multi-download blocker.

The NetErrorAutoReloader uses an exponential backoff schedule for retries: 1 second, 5 seconds, 30 seconds, 1 minute, 5 minutes, 10 minutes, and 30 minutes for subsequent attempts. An attacker can leverage this by using a service worker to serve a download on the first retry (after 1 second), causing the error page to persist. The second retry (after 5 seconds) is then used to redirect the user back to the attacker’s page, restarting the entire attack loop and allowing unlimited downloads without any user interaction.

Breakdown of the attack
  1. Attacker automatically redirects the user to a page that triggers a chain of 20 redirects.
  2. At the 21st redirect, Chrome triggers ERR_TOO_MANY_REDIRECTS and shows an error page.
  3. ShouldAutoReload() returns TRUE because ERR_TOO_MANY_REDIRECTS is not in the exclusion list.
  4. After a 1-second delay (first retry), ReloadMainFrame() reloads the failed URL.
  5. The service worker serves a downloadable file. The download is initiated without requiring a user gesture, and the error page remains.
  6. After a 5-second delay (second retry), the auto-reloader retries again. This time, the service worker redirects back to the attacker’s page.
  7. The attack loop restarts, allowing the attacker to trigger unlimited downloads.

I have also attached a video reproducing the attack (repro.mp4).

BISECT

By doing an initial bisect, I found that the issue was introduced between 791932 and 791966 (https://chromium.googlesource.com/chromium/src/+log/74fcfa083f8f12b9a7e5181176921dc6a2b2d5de..281c417f3a79f4220addb3592c241f9e3913bb28).

After investigating, it became clear that the commit that introduced the issue is https://chromium.googlesource.com/chromium/src/+/4408a0fab85c8a2d4aafe3ede4a42524109dbb15, and it landed on M86.0.4215.0.

VERSION

Chrome Version: 143.0.7499.170 (Stable)
Chrome Version: 144.0.7559.31 (Beta)
Chrome Version: 145.0.7587.5 (Dev)
Chrome Version: 145.0.7618.0 (Canary)
Operating System: Windows 11 24H2

REPRODUCTION CASE

Steps to setup the PoC
  1. Download the following files: index.html and sw.js.
  2. Move all files into the same folder.
  3. Serve the files using a local web server (e.g., python -m http.server 8080).
Steps to reproduce the issue
  1. Navigate to http://localhost:8080/index.html.
  2. Chrome will show an ERR_TOO_MANY_REDIRECTS error page. After 1 second (first retry), NetErrorAutoReloader automatically retries the last URL.
  3. The service worker serves the download file. Notice that bypass.txt is downloaded without additional user interaction.
  4. After 5 seconds (second retry), the auto-reloader retries again. The service worker now redirects back to index.html.
  5. The attack loop restarts automatically, triggering another download after 1 second. This cycle repeats indefinitely, allowing unlimited downloads without any user interaction.

CREDIT INFORMATION

Reporter credit: Luan Herrera (@lbherrera_)

View on issue tracker