Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Auth
DescriptionImproper input validation in Auth
ComponentAuth
Bug ClassInput Validation
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.
Tracker497212105
Fix commit30d40b75b0d0 (chromium/src) +106/-7
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-28 (per vendor advisory)
Fix landed2026-08-07 (public git)
Disclosed2026-09-22
Patch gap46 days — fix public in source before the advisory

Background

`LoginTabHelper`
The WebContents observer in chrome/browser/ui/login/login_tab_helper.cc that detects unauthorized main-frame responses and drives the HTTP auth (401) login prompt.
`AuthChallengeInfo`
The net-layer struct describing a server or proxy authentication challenge, including its challenger origin and the is_proxy flag.
`challenger`
The url::SchemeHostPort origin that issued the auth challenge, which may differ from the navigation URL when a service worker synthesizes the response.
Service worker fetch
A renderer-controlled worker that can intercept a navigation and return a response (including a 401 with auth headers) it fetched from an arbitrary cross-origin server.

Root Cause Analysis

LoginTabHelper displays the navigation URL as the origin requesting credentials in the HTTP auth dialog, while LoginHandler::MakeInputForPasswordManager seeds the password-manager auth cache using auth_info.challenger. When a navigation’s response is supplied by a service worker, the AuthChallengeInfo travels through the renderer and can describe a challenger origin that does not match navigation_handle->GetURL(), so the dialog text (navigation URL) and the cached entry (challenger) refer to different origins. The pre-patch code in DidFinishNavigation and WillProcessMainFrameUnauthorizedResponse only checked whether an auth challenge existed, never whether the challenger matched the navigation URL, violating the invariant that the prompted origin equals the credentialed origin.

The fix adds ChallengeMatchesNavigation, which suppresses both the blank-page throttle result and the prompt when a non-proxy challenger does not equal url::SchemeHostPort(navigation_handle->GetURL()), and upgrades the corresponding DCHECK in MakeInputForPasswordManager to a CHECK so the mismatch cannot be bypassed in release builds.

Key insight
The core mistake was gating the login prompt solely on the presence of an auth challenge without confirming that its challenger origin matched the navigation URL shown to the user, allowing a spoofed origin to solicit and cache credentials; the fix enforces challenge.is_proxy || challenge.challenger == url::SchemeHostPort(url) before ever showing the prompt.

Attack Path

  1. Register a malicious service worker An attacker controls a page under an origin the victim trusts (or a compromised origin) and registers a service worker able to intercept main-frame navigations.
  2. Synthesize a mismatched 401 The worker fetches a 401 response carrying a WWW-Authenticate challenge from an attacker-chosen origin and returns it for a navigation to a different, victim-facing URL, so AuthChallengeInfo::challenger disagrees with navigation_handle->GetURL().
  3. Prompt shows the wrong origin LoginTabHelper cancels the navigation to a blank page and shows the auth dialog labeled with the trusted navigation URL, while the challenge actually belongs to the attacker’s challenger origin.
  4. Credentials cached under attacker origin When the user enters credentials, MakeInputForPasswordManager builds the form from auth_info.challenger, so the entered credentials are associated with the attacker-controlled origin rather than the displayed one (inferred).

Impact Assessment

An attacker can present an HTTP authentication dialog attributed to a trusted origin while the supplied credentials are keyed to a different, attacker-influenced challenger origin, resulting in credential disclosure/spoofing in the browser UI context; the precondition is the ability to serve a service-worker-synthesized 401 whose challenge origin differs from the navigation URL. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. Any broader credential theft beyond the mismatched dialog/cache behavior demonstrated by the patch is (inferred).

Changed Functions

FunctionChangeNotes
TEST_F
chrome/browser/ui/login/login_handler_unittest.cc
modified
TEST
chrome/browser/ui/login/login_handler_unittest.cc
modified

Files Changed

  • chrome/browser/ui/login/login_handler.h
  • chrome/browser/ui/login/login_handler.cc
  • chrome/browser/ui/login/login_handler_unittest.cc
  • chrome/browser/ui/login/login_tab_helper.cc

Audit Directions

  • Prompt-vs-cache origin mismatch
    Audit any UI that displays one origin (navigation URL) while a security decision or cache write uses another field (challenger), and require they be reconciled before user interaction.
  • Renderer-influenced auth metadata
    Review paths where AuthChallengeInfo or similar structs pass through the renderer or service workers, treating challenger and related fields as untrusted until validated against the browser-side navigation URL.
  • DCHECK-only invariants
    Search for DCHECK guards on security-relevant origin/identity equality that are absent in release builds, and promote them to CHECK or explicit early-return handling.
From 30d40b75b0d01e67c73556ce64624ff9c11ab520 Mon Sep 17 00:00:00 2001
From: Carlos IL <carlosil@chromium.org>
Date: Fri, 07 Aug 2026 11:11:29 -0700
Subject: [PATCH] Ignore main-frame auth challenges from a different origin

LoginTabHelper shows the navigation URL as the requesting origin in the
HTTP auth dialog and seeds the auth cache from
AuthChallengeInfo::challenger when credentials are entered. For
navigations whose response is supplied by a service worker, the
AuthChallengeInfo travels through the renderer and may describe a
different origin than the navigation URL, in which case the dialog text
would not match the entry that ends up in the auth cache.

Suppress the prompt (and the blank-page navigation throttle result that
precedes it) when a non-proxy challenger does not match the navigation
URL, matching the existing DCHECK in
LoginHandler::MakeInputForPasswordManager.

Also upgrade the DCHECK to CHECK in LoginHandler::MakeInputForPasswordManager
to prevent bypass in release builds.

Bug: 497212105
Change-Id: If0600a13e2ae7873d971ee127fc158c7f4231fc5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8226484
Commit-Queue: Carlos IL <carlosil@chromium.org>
Reviewed-by: Mustafa Emre Acer <meacer@chromium.org>
Auto-Submit: Carlos IL <carlosil@chromium.org>
Commit-Queue: Mustafa Emre Acer <meacer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1675830}
---
diff --git a/chrome/browser/ui/login/login_handler.cc b/chrome/browser/ui/login/login_handler.cc
index cb2a7b7..abca7d4e 100644
--- a/chrome/browser/ui/login/login_handler.cc
+++ b/chrome/browser/ui/login/login_handler.cc
@@ -330,8 +330,8 @@
     dialog_form.scheme = PasswordForm::Scheme::kOther;
   }
   dialog_form.url = auth_info.challenger.GetURL();
-  DCHECK(auth_info.is_proxy ||
-         auth_info.challenger == url::SchemeHostPort(request_url));
+  CHECK(auth_info.is_proxy ||
+        auth_info.challenger == url::SchemeHostPort(request_url));
   dialog_form.signon_realm = GetSignonRealm(dialog_form.url, auth_info);
   return dialog_form;
 }
diff --git a/chrome/browser/ui/login/login_handler.h b/chrome/browser/ui/login/login_handler.h
index a52974f..873d2f607 100644
--- a/chrome/browser/ui/login/login_handler.h
+++ b/chrome/browser/ui/login/login_handler.h
@@ -108,6 +108,7 @@
 
  private:
   FRIEND_TEST_ALL_PREFIXES(LoginHandlerTest, DialogStringsAndRealm);
+  FRIEND_TEST_ALL_PREFIXES(LoginHandlerTest, MakeInputForPasswordManager);
 
   // When any handler finishes, called on every other handler. |username| and
   // |password| are only valid if |supplied| is true. If |supplied| is false
diff --git a/chrome/browser/ui/login/login_tab_helper.cc b/chrome/browser/ui/login/login_tab_helper.cc
index 12e2c2c8..fe68c72 100644
--- a/chrome/browser/ui/login/login_tab_helper.cc
+++ b/chrome/browser/ui/login/login_tab_helper.cc
@@ -18,6 +18,20 @@
 #include "net/http/http_response_headers.h"
 #include "net/http/http_status_code.h"
 #include "services/network/public/mojom/network_context.mojom.h"
+#include "url/scheme_host_port.h"
+
+namespace {
+
+// Returns true if `challenge` is consistent with a navigation to `url`. The
+// login prompt displays the navigation URL as the origin asking for
+// credentials, so a server challenge for a different origin must not be shown
+// to the user or used to populate the auth cache.
+bool ChallengeMatchesNavigation(const net::AuthChallengeInfo& challenge,
+                                const GURL& url) {
+  return challenge.is_proxy || challenge.challenger == url::SchemeHostPort(url);
+}
+
+}  // namespace
 
 LoginTabHelper::~LoginTabHelper() = default;
 
@@ -73,7 +87,10 @@
     return;
   }
 
-  if (!navigation_handle->GetAuthChallengeInfo()) {
+  if (!navigation_handle->GetAuthChallengeInfo() ||
+      !ChallengeMatchesNavigation(
+          navigation_handle->GetAuthChallengeInfo().value(),
+          navigation_handle->GetURL())) {
     return;
   }
 
@@ -155,10 +172,14 @@
     return content::NavigationThrottle::PROCEED;
   }
 
-  // Do not cancel the navigation if there is no auth challenge. We only want to
-  // cancel the navigation below to show a blank page if there is an auth
-  // challenge for which to show a login prompt.
-  if (!navigation_handle->GetAuthChallengeInfo()) {
+  // Do not cancel the navigation if there is no auth challenge, or if the
+  // challenge does not match the navigation URL. We only want to cancel the
+  // navigation below to show a blank page if there is an auth challenge for
+  // which to show a login prompt.
+  if (!navigation_handle->GetAuthChallengeInfo() ||
+      !ChallengeMatchesNavigation(
+          navigation_handle->GetAuthChallengeInfo().value(),
+          navigation_handle->GetURL())) {
     return content::NavigationThrottle::PROCEED;
   }
 
diff --git a/chrome/browser/ui/login/login_handler_unittest.cc b/chrome/browser/ui/login/login_handler_unittest.cc
index a9ba34a..cf8fe74 100644
--- a/chrome/browser/ui/login/login_handler_unittest.cc
+++ b/chrome/browser/ui/login/login_handler_unittest.cc
@@ -177,3 +177,80 @@
       helper->WillProcessMainFrameUnauthorizedResponse(&handle);
   EXPECT_EQ(content::NavigationThrottle::CANCEL, result.action());
 }
+
+// Tests that WillProcessMainFrameUnauthorizedResponse() cancels the navigation
+// to show a login prompt when the auth challenge's challenger matches the
+// navigation URL.
+TEST_F(LoginHandlerWithWebContentsTest, MatchingChallengerCancelsForPrompt) {
+  LoginTabHelper::CreateForWebContents(web_contents());
+  LoginTabHelper* helper = LoginTabHelper::FromWebContents(web_contents());
+
+  net::AuthChallengeInfo challenge;
+  challenge.is_proxy = false;
+  challenge.challenger = url::SchemeHostPort(GURL("https://requested.test"));
+
+  content::MockNavigationHandle handle;
+  handle.set_url(GURL("https://requested.test/page"));
+  handle.SetAuthChallengeInfo(challenge);
+  handle.set_global_request_id({network::OriginatingProcessId::browser(), 1});
+
+  content::NavigationThrottle::ThrottleCheckResult result =
+      helper->WillProcessMainFrameUnauthorizedResponse(&handle);
+  EXPECT_EQ(content::NavigationThrottle::CANCEL, result.action());
+}
+
+// Tests that WillProcessMainFrameUnauthorizedResponse() does not cancel the
+// navigation to show a login prompt when the auth challenge's challenger does
+// not match the navigation URL. This can occur when a service worker responds
+// to a navigation with a 401 response that it fetched from a different origin.
+TEST_F(LoginHandlerWithWebContentsTest, MismatchedChallengerDoesNotShowPrompt) {
+  LoginTabHelper::CreateForWebContents(web_contents());
+  LoginTabHelper* helper = LoginTabHelper::FromWebContents(web_contents());
+
+  net::AuthChallengeInfo challenge;
+  challenge.is_proxy = false;
+  challenge.challenger = url::SchemeHostPort(GURL("https://other.test"));
+
+  content::MockNavigationHandle handle;
+  handle.set_url(GURL("https://requested.test/page"));
+  handle.SetAuthChallengeInfo(challenge);
+  handle.set_global_request_id({network::OriginatingProcessId::browser(), 1});
+
+  content::NavigationThrottle::ThrottleCheckResult result =
+      helper->WillProcessMainFrameUnauthorizedResponse(&handle);
+  EXPECT_EQ(content::NavigationThrottle::PROCEED, result.action());
+}
+
+// Tests that proxy authentication challenges are processed for prompts even
+// when the proxy challenger host differs from the navigation URL.
+TEST_F(LoginHandlerWithWebContentsTest, ProxyChallengerCancelsForPrompt) {
+  LoginTabHelper::CreateForWebContents(web_contents());
+  LoginTabHelper* helper = LoginTabHelper::FromWebContents(web_contents());
+
+  net::AuthChallengeInfo challenge;
+  challenge.is_proxy = true;
+  challenge.challenger = url::SchemeHostPort(GURL("http://proxy.test:8080"));
+
+  content::MockNavigationHandle handle;
+  handle.set_url(GURL("https://requested.test/page"));
+  handle.SetAuthChallengeInfo(challenge);
+  handle.set_global_request_id({network::OriginatingProcessId::browser(), 1});
+
+  content::NavigationThrottle::ThrottleCheckResult result =
+      helper->WillProcessMainFrameUnauthorizedResponse(&handle);
+  EXPECT_EQ(content::NavigationThrottle::CANCEL, result.action());
+}
+
+TEST(LoginHandlerTest, MakeInputForPasswordManager) {
+  GURL request_url("https://requested.test/page");
+  net::AuthChallengeInfo auth_info;
+  auth_info.is_proxy = false;
+  auth_info.challenger = url::SchemeHostPort(request_url);
+  auth_info.scheme = "basic";
+  auth_info.realm = "realm";
+
+  password_manager::PasswordForm form =
+      LoginHandler::MakeInputForPasswordManager(request_url, auth_info);
+  EXPECT_EQ(form.url, GURL("https://requested.test"));
+  EXPECT_EQ(form.scheme, password_manager::PasswordForm::Scheme::kBasic);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/login/login_handler_unittest.cc b/chrome/browser/ui/login/login_handler_unittest.cc
index a9ba34a..cf8fe74 100644
--- a/chrome/browser/ui/login/login_handler_unittest.cc
+++ b/chrome/browser/ui/login/login_handler_unittest.cc
@@ -177,3 +177,80 @@
       helper->WillProcessMainFrameUnauthorizedResponse(&handle);
   EXPECT_EQ(content::NavigationThrottle::CANCEL, result.action());
 }
+
+// Tests that WillProcessMainFrameUnauthorizedResponse() cancels the navigation
+// to show a login prompt when the auth challenge's challenger matches the
+// navigation URL.
+TEST_F(LoginHandlerWithWebContentsTest, MatchingChallengerCancelsForPrompt) {
+  LoginTabHelper::CreateForWebContents(web_contents());
+  LoginTabHelper* helper = LoginTabHelper::FromWebContents(web_contents());
+
+  net::AuthChallengeInfo challenge;
+  challenge.is_proxy = false;
+  challenge.challenger = url::SchemeHostPort(GURL("https://requested.test"));
+
+  content::MockNavigationHandle handle;
+  handle.set_url(GURL("https://requested.test/page"));
+  handle.SetAuthChallengeInfo(challenge);
+  handle.set_global_request_id({network::OriginatingProcessId::browser(), 1});
+
+  content::NavigationThrottle::ThrottleCheckResult result =
+      helper->WillProcessMainFrameUnauthorizedResponse(&handle);
+  EXPECT_EQ(content::NavigationThrottle::CANCEL, result.action());
+}
+
+// Tests that WillProcessMainFrameUnauthorizedResponse() does not cancel the
+// navigation to show a login prompt when the auth challenge's challenger does
+// not match the navigation URL. This can occur when a service worker responds
+// to a navigation with a 401 response that it fetched from a different origin.
+TEST_F(LoginHandlerWithWebContentsTest, MismatchedChallengerDoesNotShowPrompt) {
+  LoginTabHelper::CreateForWebContents(web_contents());
+  LoginTabHelper* helper = LoginTabHelper::FromWebContents(web_contents());
+
+  net::AuthChallengeInfo challenge;
+  challenge.is_proxy = false;
+  challenge.challenger = url::SchemeHostPort(GURL("https://other.test"));
+
+  content::MockNavigationHandle handle;
+  handle.set_url(GURL("https://requested.test/page"));
+  handle.SetAuthChallengeInfo(challenge);
+  handle.set_global_request_id({network::OriginatingProcessId::browser(), 1});
+
+  content::NavigationThrottle::ThrottleCheckResult result =
+      helper->WillProcessMainFrameUnauthorizedResponse(&handle);
+  EXPECT_EQ(content::NavigationThrottle::PROCEED, result.action());
+}
+
+// Tests that proxy authentication challenges are processed for prompts even
+// when the proxy challenger host differs from the navigation URL.
+TEST_F(LoginHandlerWithWebContentsTest, ProxyChallengerCancelsForPrompt) {
+  LoginTabHelper::CreateForWebContents(web_contents());
+  LoginTabHelper* helper = LoginTabHelper::FromWebContents(web_contents());
+
+  net::AuthChallengeInfo challenge;
+  challenge.is_proxy = true;
+  challenge.challenger = url::SchemeHostPort(GURL("http://proxy.test:8080"));
+
+  content::MockNavigationHandle handle;
+  handle.set_url(GURL("https://requested.test/page"));
+  handle.SetAuthChallengeInfo(challenge);
+  handle.set_global_request_id({network::OriginatingProcessId::browser(), 1});
+
+  content::NavigationThrottle::ThrottleCheckResult result =
+      helper->WillProcessMainFrameUnauthorizedResponse(&handle);
+  EXPECT_EQ(content::NavigationThrottle::CANCEL, result.action());
+}
+
+TEST(LoginHandlerTest, MakeInputForPasswordManager) {
+  GURL request_url("https://requested.test/page");
+  net::AuthChallengeInfo auth_info;
+  auth_info.is_proxy = false;
+  auth_info.challenger = url::SchemeHostPort(request_url);
+  auth_info.scheme = "basic";
+  auth_info.realm = "realm";
+
+  password_manager::PasswordForm form =
+      LoginHandler::MakeInputForPasswordManager(request_url, auth_info);
+  EXPECT_EQ(form.url, GURL("https://requested.test"));
+  EXPECT_EQ(form.scheme, password_manager::PasswordForm::Scheme::kBasic);
+}
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.