Medium chrome Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in DevTools
DescriptionIncorrect reference resolution in DevTools
ComponentDevTools
Bug ClassLogic Error
Tracker497350668
Fix commit4cde886255f5 (chromium/src) +518/-33
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
content/browser/devtools/devtools_url_loader_interceptor.cc
modified
DevToolsURLLoaderInterceptorTest
content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
modified
TestContinueRequestCallback
content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
modified
if
content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
modified

Files Changed

  • content/browser/devtools/devtools_url_loader_interceptor.cc
  • content/browser/devtools/devtools_url_loader_interceptor.h
  • content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
From 4cde886255f58f5168ceca9ecb4e086459e11e6c Mon Sep 17 00:00:00 2001
From: Jack Franklin <[email protected]>
Date: Tue, 08 Sep 2026 06:40:11 -0700
Subject: [PATCH] Route DevTools auth challenges to innermost interceptor handling auth

When multiple DevToolsURLLoaderInterceptor instances are daisy-chained
(e.g., nested targets or sessions), authentication challenges from the
network service were previously routed via a singleton map holding at
most one InterceptionJob per GlobalRequestID. This caused challenges to
route arbitrarily to whichever job registered first (the outermost
interceptor), even if that session did not handle authentication or an
inner session did.

Maintain an in-flight job stack (JobStack) per GlobalRequestID across
interceptors. As requests pass through daisy-chained proxies from
outermost to innermost, each proxy registers its InterceptionJob in
sequence. When HandleAuthRequest receives an HTTP 401/407 challenge,
iterate the stack in reverse from innermost (closest to the network) to
outermost, routing the challenge to the first job where CanHandleAuth()
is true (i.e. state_ == kRequestSent, stages_ contains kRequest, and
handle_auth_ is true). If no job on the stack handles authentication or
the stack is empty, fall back to default authentication handling.

TAG=agy
CONV=a6601da1-c2f1-49f2-bc33-a5b6ea69e592

Bug: 497350668
Change-Id: I8ef3bc3f2773c7680f07ec3313ea1fc60760f003
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8366450
Auto-Submit: Jack Franklin <[email protected]>
Commit-Queue: Jack Franklin <[email protected]>
Reviewed-by: Alex Rudenko <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1693744}
---
diff --git a/content/browser/devtools/devtools_url_loader_interceptor.cc b/content/browser/devtools/devtools_url_loader_interceptor.cc
index 8cb7970f..caa8069e 100644
--- a/content/browser/devtools/devtools_url_loader_interceptor.cc
+++ b/content/browser/devtools/devtools_url_loader_interceptor.cc
@@ -11,6 +11,7 @@
 #include "base/barrier_closure.h"
 #include "base/base64.h"
 #include "base/byte_size.h"
+#include "base/containers/adapters.h"
 #include "base/functional/bind.h"
 #include "base/memory/raw_ptr.h"
 #include "base/memory/raw_ref.h"
@@ -408,12 +409,8 @@
                         public network::mojom::URLLoader,
                         public network::mojom::TrustedHeaderClient {
  public:
-  static InterceptionJob* FindByRequestId(
-      const GlobalRequestID& global_req_id) {
-    const auto& map = GetInterceptionJobMap();
-    auto it = map.find(global_req_id);
-    return it == map.end() ? nullptr : it->second;
-  }
+  const GlobalRequestID& global_req_id() const { return global_req_id_; }
+  bool CanHandleAuth() const;
 
   InterceptionJob(
       DevToolsURLLoaderInterceptor* interceptor,
@@ -448,16 +445,12 @@
       DevToolsURLLoaderInterceptor::HandleAuthRequestCallback callback);
 
  private:
-  static std::map<GlobalRequestID, InterceptionJob*>& GetInterceptionJobMap() {
-    static base::NoDestructor<std::map<GlobalRequestID, InterceptionJob*>> inst;
-    return *inst;
-  }
-
   ~InterceptionJob() override {
-    if (registered_in_global_request_map_) {
-      size_t erased = GetInterceptionJobMap().erase(global_req_id_);
-      CHECK_EQ(1lu, erased, base::NotFatalUntil::M159);
+    if (pending_auth_callback_) {
+      std::move(pending_auth_callback_)
+          .Run(/*use_fallback=*/true, std::nullopt);
     }
+    DevToolsURLLoaderInterceptor::UnregisterJob(this);
   }
 
   Response InnerContinueRequest(std::unique_ptr<Modifications> modifications);
@@ -612,7 +605,6 @@
   std::unique_ptr<ResponseMetadata> response_metadata_;
   std::vector<net::SourceStreamType> client_side_content_decoding_types_;
   mojo::ScopedDataPipeConsumerHandle body_;
-  bool registered_in_global_request_map_;
 
   std::optional<std::pair<net::RequestPriority, int32_t>> priority_;
   DevToolsURLLoaderInterceptor::HandleAuthRequestCallback
@@ -647,6 +639,11 @@
   base::WeakPtrFactory<InterceptionJob> weak_ptr_factory_{this};
 };
 
+bool InterceptionJob::CanHandleAuth() const {
+  return state_ == kRequestSent && stages_.Has(InterceptionStage::kRequest) &&
+         interceptor_ && interceptor_->handle_auth_;
+}
+
 void DevToolsURLLoaderInterceptor::CreateJob(
     const base::UnguessableToken& frame_token,
     int32_t process_id,
@@ -935,15 +932,64 @@
   target_url_loader_header_client_.reset();
 }
 
+namespace {
+
+// Tracks active InterceptionJobs per GlobalRequestID across all interceptors.
+// When DevTools interceptors are daisy-chained (e.g. nested targets or
+// sessions), each proxy creates an InterceptionJob in sequence from outermost
+// (client-facing) to innermost (network-facing). Storing them in creation order
+// forms an outer-to-inner stack. Accessed exclusively on the UI thread.
+using JobStack = std::vector<raw_ptr<InterceptionJob>>;
+
+std::map<GlobalRequestID, JobStack>& GetInFlightJobStackMap() {
+  static base::NoDestructor<std::map<GlobalRequestID, JobStack>> inst;
+  return *inst;
+}
+
+}  // namespace
+
+// static
+void DevToolsURLLoaderInterceptor::RegisterJob(InterceptionJob* job) {
+  DCHECK_CURRENTLY_ON(BrowserThread::UI);
+  GetInFlightJobStackMap()[job->global_req_id()].push_back(job);
+}
+
+// static
+void DevToolsURLLoaderInterceptor::UnregisterJob(InterceptionJob* job) {
+  DCHECK_CURRENTLY_ON(BrowserThread::UI);
+  auto& map = GetInFlightJobStackMap();
+  auto it = map.find(job->global_req_id());
+  if (it == map.end()) {
+    return;
+  }
+  std::erase(it->second, job);
+  if (it->second.empty()) {
+    map.erase(it);
+  }
+}
+
 // static
 void DevToolsURLLoaderInterceptor::HandleAuthRequest(
     GlobalRequestID req_id,
     const net::AuthChallengeInfo& auth_info,
     HandleAuthRequestCallback callback) {
-  if (auto* job = InterceptionJob::FindByRequestId(req_id))
-    job->OnAuthRequest(auth_info, std::move(callback));
-  else
-    std::move(callback).Run(true, std::nullopt);
+  DCHECK_CURRENTLY_ON(BrowserThread::UI);
+  auto& map = GetInFlightJobStackMap();
+  auto it = map.find(req_id);
+  if (it != map.end()) {
+    // Traverse the stack from innermost (closest to the network) to outermost.
+    // The innermost interceptor directly initiated the network request that
+    // received the HTTP 401/407 challenge, so it takes precedence to handle
+    // authentication. If it did not opt into handling auth, the challenge
+    // bubbles outward.
+    for (InterceptionJob* job : base::Reversed(it->second)) {
+      if (job->CanHandleAuth()) {
+        job->OnAuthRequest(auth_info, std::move(callback));
+        return;
+      }
+    }
+  }
+  std::move(callback).Run(true, std::nullopt);
 }
 
 DevToolsURLLoaderInterceptor::DevToolsURLLoaderInterceptor(
@@ -1092,11 +1138,7 @@
   loader_receiver_.set_disconnect_handler(
       base::BindOnce(&InterceptionJob::Shutdown, base::Unretained(this)));
 
-  auto& job_map = GetInterceptionJobMap();
-  // TODO(caseq): for now, all auth requests will go to the top-level job.
-  // Figure out if we need anything smarter here.
-  registered_in_global_request_map_ =
-      job_map.emplace(global_req_id_, this).second;
+  DevToolsURLLoaderInterceptor::RegisterJob(this);
 
   url_chain_.push_back(create_loader_params_->request.url);
 
@@ -2215,12 +2257,8 @@
   CHECK(pending_auth_callback_.is_null(), base::NotFatalUntil::M159);
   CHECK_EQ(ResolutionState::kNone, waiting_for_resolution_,
            base::NotFatalUntil::M159);
+  DCHECK(CanHandleAuth());
 
-  if (!stages_.Has(InterceptionStage::kRequest) || !interceptor_ ||
-      !interceptor_->handle_auth_) {
-    std::move(callback).Run(true, std::nullopt);
-    return;
-  }
   state_ = State::kAuthRequired;
   auto request_info = BuildRequestInfo(nullptr);
   request_info->auth_challenge =
diff --git a/content/browser/devtools/devtools_url_loader_interceptor.h b/content/browser/devtools/devtools_url_loader_interceptor.h
index c6ff245..8dad74a 100644
--- a/content/browser/devtools/devtools_url_loader_interceptor.h
+++ b/content/browser/devtools/devtools_url_loader_interceptor.h
@@ -75,7 +75,7 @@
                               mojo::ScopedDataPipeConsumerHandle,
                               const std::string& mime_type)>;
 
-  struct AuthChallengeResponse {
+  struct CONTENT_EXPORT AuthChallengeResponse {
     enum ResponseType {
       kDefault,
       kCancelAuth,
@@ -93,7 +93,7 @@
     const net::AuthCredentials credentials;
   };
 
-  struct Modifications {
+  struct CONTENT_EXPORT Modifications {
     using HeadersVector = std::vector<std::pair<std::string, std::string>>;
 
     Modifications();
@@ -177,12 +177,19 @@
     const RequestInterceptedCallback callback;
   };
 
+  // Called with (use_fallback, credentials). If `use_fallback` is true,
+  // DevTools declines to handle the challenge and the network stack should
+  // proceed with default auth handling. If false, `credentials` contains
+  // supplied credentials or std::nullopt if authentication was canceled.
   using HandleAuthRequestCallback =
       base::OnceCallback<void(bool use_fallback,
                               const std::optional<net::AuthCredentials>&)>;
   using CheckCookieAccessCallback =
       base::RepeatingCallback<bool(const net::CanonicalCookie&)>;
-  // Can only be called on the IO thread.
+  // Routes an authentication challenge to the innermost interceptor with auth
+  // handling enabled for the matching request. Falls back to default handling
+  // if no interceptors handle auth or no matching job exists.
+  // Can only be called on the UI thread.
   static void HandleAuthRequest(GlobalRequestID req_id,
                                 const net::AuthChallengeInfo& auth_info,
                                 HandleAuthRequestCallback callback);
@@ -270,6 +277,11 @@
     jobs_by_global_req_id_.emplace(global_req_id, job);
   }
 
+  // Registers/unregisters an InterceptionJob with the process-global in-flight
+  // job stack map used for routing network-originating auth challenges.
+  static void RegisterJob(InterceptionJob* job);
+  static void UnregisterJob(InterceptionJob* job);
+
   const RequestInterceptedCallback request_intercepted_callback_;
   const CheckCookieAccessCallback cookie_access_callback_;
 
diff --git a/content/browser/devtools/devtools_url_loader_interceptor_unittest.cc b/content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
index f7c17b2f2..71c756d 100644
--- a/content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
+++ b/content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
@@ -12,6 +12,7 @@
 #include "base/run_loop.h"
 #include "base/test/bind.h"
 #include "base/unguessable_token.h"
+#include "content/public/common/child_process_id_util.h"
 #include "content/public/test/browser_task_environment.h"
 #include "content/public/test/test_storage_partition.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
@@ -19,6 +20,7 @@
 #include "mojo/public/cpp/bindings/receiver_set.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "mojo/public/cpp/test_support/test_utils.h"
+#include "net/base/auth.h"
 #include "net/cookies/cookie_options.h"
 #include "net/cookies/cookie_partition_key_collection.h"
 #include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
@@ -74,7 +76,8 @@
 }  // namespace
 
 // Tests DevToolsURLLoaderInterceptor and DevToolsURLLoaderFactoryProxy,
-// verifying request ID uniqueness per process and bad message enforcement.
+// verifying request ID uniqueness per process, bad message enforcement, and
+// auth challenge routing across daisy-chained interceptors.
 class DevToolsURLLoaderInterceptorTest : public testing::Test {
  public:
   struct StartedLoader {
@@ -137,6 +140,7 @@
         result.client->CreateRemote(),
         net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
     run_loop.Run();
+
     result.interception_id = last_interception_id_;
     return result;
   }
@@ -151,6 +155,17 @@
   base::OnceClosure on_intercepted_;
 };
 
+class TestContinueRequestCallback
+    : public DevToolsURLLoaderInterceptor::ContinueInterceptedRequestCallback {
+ public:
+  void sendSuccess() override {}
+  void sendFailure(const protocol::Response& response) override {
+    ADD_FAILURE() << "ContinueInterceptedRequest failed: "
+                  << response.Message();
+  }
+  void fallThrough() override {}
+};
+
 // Verifies that a renderer process attempting to reuse an in-flight request ID
 // is terminated via mojo::ReportBadMessage to prevent request hijacking.
 TEST_F(DevToolsURLLoaderInterceptorTest,
@@ -310,4 +325,424 @@
   EXPECT_EQ(1, target_factory_.NumPending());
 }
 
+// Verifies that in a daisy-chained interceptor setup, an auth challenge is
+// routed to the interceptor configured to handle auth (handle_auth = true),
+// bypassing any interceptor that does not handle auth.
+TEST_F(DevToolsURLLoaderInterceptorTest,
+       AuthChallengeRoutesToInterceptorHandlingAuth) {
+  constexpr int kRendererProcessId = 42;
+  const base::UnguessableToken frame_token = base::UnguessableToken::Create();
+
+  std::unique_ptr<InterceptedRequestInfo> info1;
+  base::RunLoop loop1;
+  auto interceptor1 =
+      std::make_unique<DevToolsURLLoaderInterceptor>(base::BindLambdaForTesting(
+          [&](std::unique_ptr<InterceptedRequestInfo> info) {
+            info1 = std::move(info);
+            loop1.Quit();
+          }));
+  std::vector<DevToolsURLLoaderInterceptor::Pattern> patterns1;
+  patterns1.emplace_back("*", base::flat_set<blink::mojom::ResourceType>(),
+                         DevToolsURLLoaderInterceptor::kRequest);
+  interceptor1->SetPatterns(std::move(patterns1), /*handle_auth=*/false);
+
+  std::unique_ptr<InterceptedRequestInfo> info2;
+  std::unique_ptr<InterceptedRequestInfo> auth_info2;
+  base::RunLoop loop2;
+  base::RunLoop auth_loop;
+  auto interceptor2 =
+      std::make_unique<DevToolsURLLoaderInterceptor>(base::BindLambdaForTesting(
+          [&](std::unique_ptr<InterceptedRequestInfo> info) {
+            if (info->auth_challenge) {
+              auth_info2 = std::move(info);
+              auth_loop.Quit();
+            } else {
+              info2 = std::move(info);
+              loop2.Quit();
+            }
+          }));
+  std::vector<DevToolsURLLoaderInterceptor::Pattern> patterns2;
+  patterns2.emplace_back("*", base::flat_set<blink::mojom::ResourceType>(),
+                         DevToolsURLLoaderInterceptor::kRequest);
+  interceptor2->SetPatterns(std::move(patterns2), /*handle_auth=*/true);
+
+  // Chain interceptor1 then interceptor2. Because each proxy wraps the
+  // previous override's target receiver, interceptor1 becomes the outermost
+  // proxy (client-facing) and interceptor2 becomes the innermost proxy
+  // (network-facing).
+  network::mojom::URLLoaderFactoryOverride devtools_override;
+  bool created1 = interceptor1->CreateProxyForInterception(
+      kRendererProcessId, &storage_partition_, frame_token,
+      /*is_navigation=*/false, /*is_download=*/false, &devtools_override,
+      /*header_client=*/nullptr);
+  ASSERT_TRUE(created1);
+
+  bool created2 = interceptor2->CreateProxyForInterception(
+      kRendererProcessId, &storage_partition_, frame_token,
+      /*is_navigation=*/false, /*is_download=*/false, &devtools_override,
+      /*header_client=*/nullptr);
+  ASSERT_TRUE(created2);
+
+  target_factory_.Clone(
+      std::move(devtools_override.overridden_factory_receiver));
+  mojo::Remote<network::mojom::URLLoaderFactory> client_factory(
+      std::move(devtools_override.overriding_factory));
+
+  // Start request.
+  network::ResourceRequest request;
+  request.url = GURL("http://example.com/auth");
+  request.method = "GET";
+  mojo::Remote<network::mojom::URLLoader> loader;
+  network::TestURLLoaderClient client;
+
+  client_factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), /*request_id=*/1,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+
+  // Interceptor 1 intercepts request first.
+  loop1.Run();
+  ASSERT_TRUE(info1);
+  interceptor1->ContinueInterceptedRequest(
+      info1->interception_id,
+      std::make_unique<DevToolsURLLoaderInterceptor::Modifications>(),
+      std::make_unique<TestContinueRequestCallback>());
+
+  // Interceptor 2 intercepts request next.
+  loop2.Run();
+  ASSERT_TRUE(info2);
+  interceptor2->ContinueInterceptedRequest(
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/devtools/devtools_url_loader_interceptor_unittest.cc b/content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
index f7c17b2f2..71c756d 100644
--- a/content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
+++ b/content/browser/devtools/devtools_url_loader_interceptor_unittest.cc
@@ -12,6 +12,7 @@
 #include "base/run_loop.h"
 #include "base/test/bind.h"
 #include "base/unguessable_token.h"
+#include "content/public/common/child_process_id_util.h"
 #include "content/public/test/browser_task_environment.h"
 #include "content/public/test/test_storage_partition.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
@@ -19,6 +20,7 @@
 #include "mojo/public/cpp/bindings/receiver_set.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "mojo/public/cpp/test_support/test_utils.h"
+#include "net/base/auth.h"
 #include "net/cookies/cookie_options.h"
 #include "net/cookies/cookie_partition_key_collection.h"
 #include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
@@ -74,7 +76,8 @@
 }  // namespace
 
 // Tests DevToolsURLLoaderInterceptor and DevToolsURLLoaderFactoryProxy,
-// verifying request ID uniqueness per process and bad message enforcement.
+// verifying request ID uniqueness per process, bad message enforcement, and
+// auth challenge routing across daisy-chained interceptors.
 class DevToolsURLLoaderInterceptorTest : public testing::Test {
  public:
   struct StartedLoader {
@@ -137,6 +140,7 @@
         result.client->CreateRemote(),
         net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
     run_loop.Run();
+
     result.interception_id = last_interception_id_;
     return result;
   }
@@ -151,6 +155,17 @@
   base::OnceClosure on_intercepted_;
 };
 
+class TestContinueRequestCallback
+    : public DevToolsURLLoaderInterceptor::ContinueInterceptedRequestCallback {
+ public:
+  void sendSuccess() override {}
+  void sendFailure(const protocol::Response& response) override {
+    ADD_FAILURE() << "ContinueInterceptedRequest failed: "
+                  << response.Message();
+  }
+  void fallThrough() override {}
+};
+
 // Verifies that a renderer process attempting to reuse an in-flight request ID
 // is terminated via mojo::ReportBadMessage to prevent request hijacking.
 TEST_F(DevToolsURLLoaderInterceptorTest,
@@ -310,4 +325,424 @@
   EXPECT_EQ(1, target_factory_.NumPending());
 }
 
+// Verifies that in a daisy-chained interceptor setup, an auth challenge is
+// routed to the interceptor configured to handle auth (handle_auth = true),
+// bypassing any interceptor that does not handle auth.
+TEST_F(DevToolsURLLoaderInterceptorTest,
+       AuthChallengeRoutesToInterceptorHandlingAuth) {
+  constexpr int kRendererProcessId = 42;
+  const base::UnguessableToken frame_token = base::UnguessableToken::Create();
+
+  std::unique_ptr<InterceptedRequestInfo> info1;
+  base::RunLoop loop1;
+  auto interceptor1 =
+      std::make_unique<DevToolsURLLoaderInterceptor>(base::BindLambdaForTesting(
+          [&](std::unique_ptr<InterceptedRequestInfo> info) {
+            info1 = std::move(info);
+            loop1.Quit();
+          }));
+  std::vector<DevToolsURLLoaderInterceptor::Pattern> patterns1;
+  patterns1.emplace_back("*", base::flat_set<blink::mojom::ResourceType>(),
+                         DevToolsURLLoaderInterceptor::kRequest);
+  interceptor1->SetPatterns(std::move(patterns1), /*handle_auth=*/false);
+
+  std::unique_ptr<InterceptedRequestInfo> info2;
+  std::unique_ptr<InterceptedRequestInfo> auth_info2;
+  base::RunLoop loop2;
+  base::RunLoop auth_loop;
+  auto interceptor2 =
+      std::make_unique<DevToolsURLLoaderInterceptor>(base::BindLambdaForTesting(
+          [&](std::unique_ptr<InterceptedRequestInfo> info) {
+            if (info->auth_challenge) {
+              auth_info2 = std::move(info);
+              auth_loop.Quit();
+            } else {
+              info2 = std::move(info);
+              loop2.Quit();
+            }
+          }));
+  std::vector<DevToolsURLLoaderInterceptor::Pattern> patterns2;
+  patterns2.emplace_back("*", base::flat_set<blink::mojom::ResourceType>(),
+                         DevToolsURLLoaderInterceptor::kRequest);
+  interceptor2->SetPatterns(std::move(patterns2), /*handle_auth=*/true);
+
+  // Chain interceptor1 then interceptor2. Because each proxy wraps the
+  // previous override's target receiver, interceptor1 becomes the outermost
+  // proxy (client-facing) and interceptor2 becomes the innermost proxy
+  // (network-facing).
+  network::mojom::URLLoaderFactoryOverride devtools_override;
+  bool created1 = interceptor1->CreateProxyForInterception(
+      kRendererProcessId, &storage_partition_, frame_token,
+      /*is_navigation=*/false, /*is_download=*/false, &devtools_override,
+      /*header_client=*/nullptr);
+  ASSERT_TRUE(created1);
+
+  bool created2 = interceptor2->CreateProxyForInterception(
+      kRendererProcessId, &storage_partition_, frame_token,
+      /*is_navigation=*/false, /*is_download=*/false, &devtools_override,
+      /*header_client=*/nullptr);
+  ASSERT_TRUE(created2);
+
+  target_factory_.Clone(
+      std::move(devtools_override.overridden_factory_receiver));
+  mojo::Remote<network::mojom::URLLoaderFactory> client_factory(
+      std::move(devtools_override.overriding_factory));
+
+  // Start request.
+  network::ResourceRequest request;
+  request.url = GURL("http://example.com/auth");
+  request.method = "GET";
+  mojo::Remote<network::mojom::URLLoader> loader;
+  network::TestURLLoaderClient client;
+
+  client_factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), /*request_id=*/1,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+
+  // Interceptor 1 intercepts request first.
+  loop1.Run();
+  ASSERT_TRUE(info1);
+  interceptor1->ContinueInterceptedRequest(
+      info1->interception_id,
+      std::make_unique<DevToolsURLLoaderInterceptor::Modifications>(),
+      std::make_unique<TestContinueRequestCallback>());
+
+  // Interceptor 2 intercepts request next.
+  loop2.Run();
+  ASSERT_TRUE(info2);
+  interceptor2->ContinueInterceptedRequest(
+      info2->interception_id,
+      std::make_unique<DevToolsURLLoaderInterceptor::Modifications>(),
+      std::make_unique<TestContinueRequestCallback>());
+
+  // Both jobs forwarded the request to the network. Simulate auth challenge.
+  net::AuthChallengeInfo challenge;
+  challenge.scheme = "basic";
+  challenge.realm = "realm";
+
+  bool handle_auth_completed = false;
+  bool fallback_result = false;
+  std::optional<net::AuthCredentials> creds_result;
+  base::RunLoop finish_loop;
+
+  DevToolsURLLoaderInterceptor::HandleAuthRequest(
+      GlobalRequestID(ToOriginatingProcessIdUnsafe(kRendererProcessId),
+                      /*request_id=*/1),
+      challenge,
+      base::BindLambdaForTesting(
+          [&](bool use_fallback,
+              const std::optional<net::AuthCredentials>& creds) {
+            handle_auth_completed = true;
+            fallback_result = use_fallback;
+            creds_result = creds;
+            finish_loop.Quit();
+          }));
+
+  // Interceptor 2 handles auth, so it receives the auth challenge.
+  auth_loop.Run();
+  ASSERT_TRUE(auth_info2);
+  ASSERT_TRUE(auth_info2->auth_challenge);
+  EXPECT_EQ(auth_info2->auth_challenge->realm, "realm");
+  EXPECT_FALSE(handle_auth_completed);
+
+  // Provide credentials from Interceptor 2.
+  auto mods = std::make_unique<DevToolsURLLoaderInterceptor::Modifications>();
+  mods->auth_challenge_response =
+      std::make_unique<DevToolsURLLoaderInterceptor::AuthChallengeResponse>(
+          u"test_user", u"test_pass");
+  interceptor2->ContinueInterceptedRequest(
+      auth_info2->interception_id, std::move(mods),
+      std::make_unique<TestContinueRequestCallback>());
+
+  finish_loop.Run();
+  EXPECT_TRUE(handle_auth_completed);
+  EXPECT_FALSE(fallback_result);
+  ASSERT_TRUE(creds_result.has_value());
+  EXPECT_EQ(creds_result->username(), u"test_user");
+  EXPECT_EQ(creds_result->password(), u"test_pass");
+
+  loader.reset();
+  client.RunUntilDisconnect();
+}
+
+// Verifies that when no interceptor handles auth, HandleAuthRequest falls back
+// to default authentication handling without error.
+TEST_F(DevToolsURLLoaderInterceptorTest,
+       AuthChallengeFallsBackWhenNoInterceptorHandlesAuth) {
+  constexpr int kRendererProcessId = 42;
+  const base::UnguessableToken frame_token = base::UnguessableToken::Create();
+
+  std::unique_ptr<InterceptedRequestInfo> info1;
+  base::RunLoop loop1;
+  auto interceptor1 =
+      std::make_unique<DevToolsURLLoaderInterceptor>(base::BindLambdaForTesting(
+          [&](std::unique_ptr<InterceptedRequestInfo> info) {
+            info1 = std::move(info);
+            loop1.Quit();
+          }));
+  std::vector<DevToolsURLLoaderInterceptor::Pattern> patterns1;
+  patterns1.emplace_back("*", base::flat_set<blink::mojom::ResourceType>(),
+                         DevToolsURLLoaderInterceptor::kRequest);
+  interceptor1->SetPatterns(std::move(patterns1), /*handle_auth=*/false);
+
+  std::unique_ptr<InterceptedRequestInfo> info2;
+  base::RunLoop loop2;
+  auto interceptor2 =
+      std::make_unique<DevToolsURLLoaderInterceptor>(base::BindLambdaForTesting(
+          [&](std::unique_ptr<InterceptedRequestInfo> info) {
+            info2 = std::move(info);
+            loop2.Quit();
+          }));
+  std::vector<DevToolsURLLoaderInterceptor::Pattern> patterns2;
+  patterns2.emplace_back("*", base::flat_set<blink::mojom::ResourceType>(),
+                         DevToolsURLLoaderInterceptor::kRequest);
+  interceptor2->SetPatterns(std::move(patterns2), /*handle_auth=*/false);
+
+  // Chain interceptor1 then interceptor2.
+  network::mojom::URLLoaderFactoryOverride devtools_override;
+  bool created1 = interceptor1->CreateProxyForInterception(
+      kRendererProcessId, &storage_partition_, frame_token,
+      /*is_navigation=*/false, /*is_download=*/false, &devtools_override,
+      /*header_client=*/nullptr);
+  ASSERT_TRUE(created1);
+
+  bool created2 = interceptor2->CreateProxyForInterception(
+      kRendererProcessId, &storage_partition_, frame_token,
+      /*is_navigation=*/false, /*is_download=*/false, &devtools_override,
+      /*header_client=*/nullptr);
+  ASSERT_TRUE(created2);
+
+  target_factory_.Clone(
+      std::move(devtools_override.overridden_factory_receiver));
+  mojo::Remote<network::mojom::URLLoaderFactory> client_factory(
+      std::move(devtools_override.overriding_factory));
+
+  network::ResourceRequest request;
+  request.url = GURL("http://example.com/noauth");
+  request.method = "GET";
+  mojo::Remote<network::mojom::URLLoader> loader;
+  network::TestURLLoaderClient client;
+
+  client_factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), /*request_id=*/1,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+
+  loop1.Run();
+  ASSERT_TRUE(info1);
+  interceptor1->ContinueInterceptedRequest(
+      info1->interception_id,
+      std::make_unique<DevToolsURLLoaderInterceptor::Modifications>(),
+      std::make_unique<TestContinueRequestCallback>());
+
+  loop2.Run();
+  ASSERT_TRUE(info2);
+  interceptor2->ContinueInterceptedRequest(
+      info2->interception_id,
+      std::make_unique<DevToolsURLLoaderInterceptor::Modifications>(),
+      std::make_unique<TestContinueRequestCallback>());
+
+  net::AuthChallengeInfo challenge;
+  challenge.scheme = "basic";
+  challenge.realm = "realm";
+
+  bool handle_auth_completed = false;
+  bool fallback_result = false;
+  std::optional<net::AuthCredentials> creds_result;
+
+  DevToolsURLLoaderInterceptor::HandleAuthRequest(
+      GlobalRequestID(ToOriginatingProcessIdUnsafe(kRendererProcessId),
+                      /*request_id=*/1),
+      challenge,
+      base::BindLambdaForTesting(
+          [&](bool use_fallback,
+              const std::optional<net::AuthCredentials>& creds) {
+            handle_auth_completed = true;
+            fallback_result = use_fallback;
+            creds_result = creds;
+          }));
+
+  EXPECT_TRUE(handle_auth_completed);
+  EXPECT_TRUE(fallback_result);
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.