High chrome UAF 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Network
DescriptionUse after free in Network
ComponentNetwork
Sub-componentNet
Bug ClassUAF
Tracker557075191
Fix commit6bc3005112c9 (chromium/src) +989/-31
CISA KEVNot listed
CreditedJonathanBouman
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
net/http/http_auth_controller.cc
modified
if
net/http/http_network_transaction.cc
modified

Files Changed

  • net/http/http_proxy_client_socket.cc
  • net/http/http_auth_controller.cc
  • net/spdy/spdy_proxy_client_socket.cc
  • net/http/http_network_transaction.cc
  • net/http/http_network_transaction.h
  • net/http/http_auth_controller_unittest.cc
  • net/quic/quic_proxy_client_socket.cc
  • net/http/http_auth_controller.h
  • net/http/http_network_transaction_unittest.cc
From 6bc3005112c94fda252f66b1ebd7b520195b3de3 Mon Sep 17 00:00:00 2001
From: Sebastien Lalancette <seblalancette@chromium.org>
Date: Fri, 25 Sep 2026 12:26:09 -0700
Subject: [PATCH] [net] Validate server certificate before generating auth token

Connection-based HTTP authentication schemes (such as NTLM and
Negotiate) derive per-connection state and channel bindings from the
server certificate presented when the handler is created. Previously,
HttpAuthController only validated certificate consistency upon receiving
a challenge in HandleAuthChallenge().

If a connection was replaced after receiving a challenge or between
handshake rounds, MaybeGenerateAuthToken() could generate and transmit
an authentication token bound to the previous connection's certificate
over a new connection presenting a different certificate.

This change passes the target connection's server certificate to
HttpAuthController::MaybeGenerateAuthToken() and invalidates the active
handler if the certificate does not match before generating an auth
token. All transaction and proxy socket callers are updated to pass the
target certificate.

Bug: 557075191
Change-Id: Ie219efcb29fa840ffeb9b181563b12663458d962
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8374421
Reviewed-by: mmenke <mmenke@chromium.org>
Commit-Queue: Sebastien Lalancette <seblalancette@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1705049}
---
diff --git a/net/http/http_auth_controller.cc b/net/http/http_auth_controller.cc
index 3a670fb76..cc5afa574 100644
--- a/net/http/http_auth_controller.cc
+++ b/net/http/http_auth_controller.cc
@@ -125,18 +125,31 @@
 
 int HttpAuthController::MaybeGenerateAuthToken(
     const HttpRequestInfo* request,
+    const SSLInfo& ssl_info,
     CompletionOnceCallback callback,
     const NetLogWithSource& caller_net_log) {
   DCHECK_CALLED_ON_VALID_THREAD(thread_checker_);
   DCHECK(!auth_info_);
+
+  // A connection-based handler derives per-connection state (such as channel
+  // bindings) from the server certificate at creation time. If the
+  // underlying connection has been replaced with one that presents a
+  // different certificate since the last challenge, that state is no longer
+  // valid for the current connection, so drop the handler instead of
+  // generating a token from it. The next challenge starts the authentication
+  // protocol over on the current connection.
+  InvalidateIfServerCertChanged(ssl_info.cert.get());
+
   bool needs_auth = HaveAuth() || SelectPreemptiveAuth(caller_net_log);
-  if (!needs_auth)
+  if (!needs_auth) {
     return OK;
+  }
   net_log_.BeginEventReferencingSource(NetLogEventType::AUTH_GENERATE_TOKEN,
                                        caller_net_log.source());
   const AuthCredentials* credentials = nullptr;
-  if (identity_.source != HttpAuth::IDENT_SRC_DEFAULT_CREDENTIALS)
+  if (identity_.source != HttpAuth::IDENT_SRC_DEFAULT_CREDENTIALS) {
     credentials = &identity_.credentials;
+  }
   DCHECK(auth_token_.empty());
   DCHECK(callback_.is_null());
   int rv = handler_->GenerateAuthToken(
@@ -161,8 +174,9 @@
 
   // Don't do preemptive authorization if the URL contains a username:password,
   // since we must first be challenged in order to use the URL's identity.
-  if (auth_url_.has_username())
+  if (auth_url_.has_username()) {
     return false;
+  }
 
   // SelectPreemptiveAuth() is on the critical path for each request, so it
   // is expected to be fast. LookupByPath() is fast in the common case, since
@@ -170,8 +184,9 @@
   // (For most users in fact, it will be 0.)
   HttpAuthCache::Entry* entry = http_auth_cache_->LookupByPath(
       auth_scheme_host_port_, target_, network_anonymization_key_, auth_path_);
-  if (!entry)
+  if (!entry) {
     return false;
+  }
 
   BindToCallingNetLog(caller_net_log);
 
@@ -226,11 +241,8 @@
   // certificate at creation time. If the underlying connection has been
   // replaced with one that presents a different certificate, that state is
   // no longer valid for the current connection, so drop the handler and
-  // start the scheme over.
-  if (handler_ && handler_->is_connection_based() &&
-      !ServerCertMatches(handler_server_cert_.get(), ssl_info.cert.get())) {
-    InvalidateCurrentHandler(INVALIDATE_HANDLER);
-  }
+  // start the authentication protocol over.
+  InvalidateIfServerCertChanged(ssl_info.cert.get());
 
   // Give the existing auth handler first try at the authentication headers.
   // This will also evict the entry in the HttpAuthCache if the previous
@@ -673,4 +685,13 @@
   InvalidateCurrentHandler(INVALIDATE_HANDLER);
 }
 
+void HttpAuthController::InvalidateIfServerCertChanged(
+    const X509Certificate* cert) {
+  DCHECK_CALLED_ON_VALID_THREAD(thread_checker_);
+  if (handler_ && handler_->is_connection_based() &&
+      !ServerCertMatches(handler_server_cert_.get(), cert)) {
+    InvalidateCurrentHandler(INVALIDATE_HANDLER);
+  }
+}
+
 }  // namespace net
diff --git a/net/http/http_auth_controller.h b/net/http/http_auth_controller.h
index 7d5cbf5e..136c79c8a 100644
--- a/net/http/http_auth_controller.h
+++ b/net/http/http_auth_controller.h
@@ -93,11 +93,20 @@
                      HttpAuthHandlerFactory* http_auth_handler_factory,
                      HostResolver* host_resolver);
 
-  // Generate an authentication token for |target| if necessary. The return
-  // value is a net error code. |OK| will be returned both in the case that
+  // Generate an authentication token for `target` if necessary. The return
+  // value is a net error code. `OK` will be returned both in the case that
   // a token is correctly generated synchronously, as well as when no tokens
   // were necessary.
+  //
+  // `ssl_info` is the certificate information for the connection the token
+  // will be sent over, and must match what HandleAuthChallenge() is given for
+  // responses on that connection. It is empty when no certificate applies to
+  // this controller's target, for example for "http" origins and for proxy
+  // auth. If a connection-based handler was created on a connection with a
+  // different server certificate, it is dropped instead of generating a token,
+  // so that authentication restarts on the current connection.
   int MaybeGenerateAuthToken(const HttpRequestInfo* request,
+                             const SSLInfo& ssl_info,
                              CompletionOnceCallback callback,
                              const NetLogWithSource& net_log);
 
@@ -207,6 +216,10 @@
   // Records the number of authentication events per authentication scheme.
   void HistogramAuthEvent(AuthEvent auth_event);
 
+  // If the current handler is connection-based and was created for a connection
+  // with a different server certificate, invalidates the handler.
+  void InvalidateIfServerCertChanged(const X509Certificate* cert);
+
   // Indicates if this handler is for Proxy auth or Server auth.
   HttpAuth::Target target_;
 
diff --git a/net/http/http_network_transaction.cc b/net/http/http_network_transaction.cc
index 1788baf8..6b28ed9 100644
--- a/net/http/http_network_transaction.cc
+++ b/net/http/http_network_transaction.cc
@@ -1007,6 +1007,15 @@
          request_->url.SchemeIs("http");
 }
 
+SSLInfo HttpNetworkTransaction::GetStreamSSLInfo() {
+  CHECK(stream_);
+  SSLInfo ssl_info;
+  if (IsSecureRequest()) {
+    stream_->GetSSLInfo(&ssl_info);
+  }
+  return ssl_info;
+}
+
 void HttpNetworkTransaction::DoCallback(int rv) {
   DCHECK_NE(rv, ERR_IO_PENDING);
   DCHECK(!callback_.is_null());
@@ -1335,8 +1344,9 @@
 
 int HttpNetworkTransaction::DoGenerateProxyAuthToken() {
   next_state_ = STATE_GENERATE_PROXY_AUTH_TOKEN_COMPLETE;
-  if (!ShouldApplyProxyAuth())
+  if (!ShouldApplyProxyAuth()) {
     return OK;
+  }
   HttpAuth::Target target = HttpAuth::AUTH_PROXY;
   GURL auth_url = AuthURL(target);
   // The proxy may have changed (for example due to fallback to another proxy
@@ -1350,13 +1360,25 @@
           url::SchemeHostPort(auth_url))) {
     auth_controllers_[target] = nullptr;
   }
-  if (!auth_controllers_[target].get())
+  if (!auth_controllers_[target].get()) {
     auth_controllers_[target] = base::MakeRefCounted<HttpAuthController>(
         target, auth_url, request_->network_anonymization_key,
         session_->http_auth_cache(), session_->http_auth_handler_factory(),
         session_->host_resolver());
+  }
+  // This is only reached for GET proxies (see ShouldApplyProxyAuth()); CONNECT
+  // proxy auth is handled by the proxy client sockets instead. GET proxy auth
+  // controllers are never given a certificate, so pass an empty SSLInfo.
+  //
+  // GetStreamSSLInfo() would also return an empty SSLInfo here, because a GET
+  // proxy is only used for "http" requests, so IsSecureRequest() is false. It
+  // is not used, to avoid implying that the proxy's certificate could show up
+  // here: GetStreamSSLInfo() only describes the destination server.
+  //
+  // As a result, GET proxy auth handlers derive no channel bindings and are not
+  // pinned to the proxy's certificate. This is legacy behavior.
   return auth_controllers_[target]->MaybeGenerateAuthToken(
-      request_, io_callback_, net_log_);
+      request_, SSLInfo(), io_callback_, net_log_);
 }
 
 int HttpNetworkTransaction::DoGenerateProxyAuthTokenComplete(int rv) {
@@ -1374,13 +1396,15 @@
         target, AuthURL(target), request_->network_anonymization_key,
         session_->http_auth_cache(), session_->http_auth_handler_factory(),
         session_->host_resolver());
-    if (request_->load_flags & LOAD_DO_NOT_USE_EMBEDDED_IDENTITY)
+    if (request_->load_flags & LOAD_DO_NOT_USE_EMBEDDED_IDENTITY) {
       auth_controllers_[target]->DisableEmbeddedIdentity();
+    }
   }
-  if (!ShouldApplyServerAuth())
+  if (!ShouldApplyServerAuth()) {
     return OK;
+  }
   return auth_controllers_[target]->MaybeGenerateAuthToken(
-      request_, io_callback_, net_log_);
+      request_, GetStreamSSLInfo(), io_callback_, net_log_);
 }
 
 int HttpNetworkTransaction::DoGenerateServerAuthTokenComplete(int rv) {
@@ -2412,8 +2436,18 @@
   if (!auth_controllers_[target].get())
     return ERR_UNEXPECTED_PROXY_AUTH;
 
+  // CONNECT proxy auth challenges do not go through this path; they are
+  // handled by the proxy client sockets. Only challenges from GET proxies, or
+  // directly from servers, reach here. A proxy auth challenge sent by a server
+  // when there is no GET proxy results in ERR_UNEXPECTED_PROXY_AUTH above.
+  //
+  // GetStreamSSLInfo() describes the connection to the destination server, so
+  // only pass it to the server auth controller. The GET proxy auth controller
+  // gets an empty SSLInfo, matching DoGenerateProxyAuthToken().
+  const SSLInfo ssl_info =
+      target == HttpAuth::AUTH_SERVER ? GetStreamSSLInfo() : SSLInfo();
   int rv = auth_controllers_[target]->HandleAuthChallenge(
-      headers, response_.ssl_info, !ShouldApplyServerAuth(), false, net_log_);
+      headers, ssl_info, !ShouldApplyServerAuth(), false, net_log_);
   if (auth_controllers_[target]->HaveAuthHandler())
     pending_auth_target_ = target;
 
diff --git a/net/http/http_network_transaction.h b/net/http/http_network_transaction.h
index 831fcf1..3c5a5de8 100644
--- a/net/http/http_network_transaction.h
+++ b/net/http/http_network_transaction.h
@@ -187,6 +187,24 @@
   // tunneled via the CONNECT method.
   bool UsingHttpProxyWithoutTunnel() const;
 
+  // Returns the SSLInfo of the destination server's connection. This is the
+  // sole source of certificate information for *server* authentication, used
+  // by both HandleAuthChallenge() and MaybeGenerateAuthToken(), so the two are
+  // guaranteed to observe the same value for a given `stream_`. Proxy
+  // authentication never uses this; it is always passed an empty SSLInfo.
+  //
+  // Returns an empty SSLInfo for non-secure requests, even if the connection
+  // uses TLS: an "http" request through an HTTPS proxy runs over TLS to that
+  // proxy, but that certificate belongs to the proxy rather than to the
+  // destination server, so server auth must not observe it.
+  //
+  // Consequently this never returns a proxy's certificate. When
+  // `IsSecureRequest()` is true the connection is either direct or tunneled
+  // through CONNECT -- a GET-to-proxy connection is impossible, because
+  // `UsingHttpProxyWithoutTunnel()` requires an "http" scheme -- so in both
+  // cases the top of the socket stack terminates at the destination server.
+  SSLInfo GetStreamSSLInfo();
+
   void DoCallback(int result);
   void OnIOComplete(int result);
 
diff --git a/net/http/http_proxy_client_socket.cc b/net/http/http_proxy_client_socket.cc
index 2b864b0..9f72c8af 100644
--- a/net/http/http_proxy_client_socket.cc
+++ b/net/http/http_proxy_client_socket.cc
@@ -339,7 +339,7 @@
 int HttpProxyClientSocket::DoGenerateAuthToken() {
   next_state_ = STATE_GENERATE_AUTH_TOKEN_COMPLETE;
   return auth_->MaybeGenerateAuthToken(
-      &request_,
+      &request_, response_.ssl_info,
       base::BindOnce(&HttpProxyClientSocket::OnIOComplete,
                      weak_factory_.GetWeakPtr()),
       net_log_);
diff --git a/net/quic/quic_proxy_client_socket.cc b/net/quic/quic_proxy_client_socket.cc
index 7ebbcf64..5a95b2f6 100644
--- a/net/quic/quic_proxy_client_socket.cc
+++ b/net/quic/quic_proxy_client_socket.cc
@@ -338,7 +338,7 @@
 int QuicProxyClientSocket::DoGenerateAuthToken() {
   next_state_ = STATE_GENERATE_AUTH_TOKEN_COMPLETE;
   return auth_->MaybeGenerateAuthToken(
-      &request_,
+      &request_, response_.ssl_info,
       base::BindOnce(&QuicProxyClientSocket::OnIOComplete,
                      weak_factory_.GetWeakPtr()),
       net_log_);
diff --git a/net/spdy/spdy_proxy_client_socket.cc b/net/spdy/spdy_proxy_client_socket.cc
index 3963896..ba4110d 100644
--- a/net/spdy/spdy_proxy_client_socket.cc
+++ b/net/spdy/spdy_proxy_client_socket.cc
@@ -397,7 +397,7 @@
 int SpdyProxyClientSocket::DoGenerateAuthToken() {
   next_state_ = STATE_GENERATE_AUTH_TOKEN_COMPLETE;
   return auth_->MaybeGenerateAuthToken(
-      &request_,
+      &request_, response_.ssl_info,
       base::BindOnce(&SpdyProxyClientSocket::OnIOComplete,
                      weak_factory_.GetWeakPtr()),
       net_log_);
diff --git a/net/http/http_auth_controller_unittest.cc b/net/http/http_auth_controller_unittest.cc
index 856ddd21..9e576967 100644
--- a/net/http/http_auth_controller_unittest.cc
+++ b/net/http/http_auth_controller_unittest.cc
@@ -96,8 +96,8 @@
   TestCompletionCallback callback;
   EXPECT_EQ(
       (run_mode == RUN_HANDLER_ASYNC) ? ERR_IO_PENDING : expected_controller_rv,
-      controller->MaybeGenerateAuthToken(&request, callback.callback(),
-                                         net_log));
+      controller->MaybeGenerateAuthToken(&request, null_ssl_info,
+                                         callback.callback(), net_log));
   if (run_mode == RUN_HANDLER_ASYNC)
     EXPECT_EQ(expected_controller_rv, callback.WaitForResult());
   EXPECT_EQ((scheme_state == SCHEME_IS_DISABLED),
@@ -285,8 +285,9 @@
   EXPECT_TRUE(controller->HaveAuth());
 
   // Should only succeed if we are using the AUTH_SCHEME_MOCK MockHandler.
-  EXPECT_EQ(OK, controller->MaybeGenerateAuthToken(
-                    &request, CompletionOnceCallback(), dummy_log));
+  EXPECT_EQ(OK,
+            controller->MaybeGenerateAuthToken(
+                &request, null_ssl_info, CompletionOnceCallback(), dummy_log));
   controller->AddAuthorizationHeader(&request_headers);
 
   // Once a token is generated, simulate the receipt of a server response
@@ -300,8 +301,9 @@
   EXPECT_FALSE(controller->IsAuthSchemeDisabled(HttpAuth::AUTH_SCHEME_BASIC));
 
   // Should only succeed if we are using the AUTH_SCHEME_BASIC MockHandler.
-  EXPECT_EQ(OK, controller->MaybeGenerateAuthToken(
-                    &request, CompletionOnceCallback(), dummy_log));
+  EXPECT_EQ(OK,
+            controller->MaybeGenerateAuthToken(
+                &request, null_ssl_info, CompletionOnceCallback(), dummy_log));
 }
 
 // Tests that a connection-based auth handler is reused when a subsequent
@@ -359,7 +361,7 @@
   controller->ResetAuth(AuthCredentials(u"user", u"pass"));
   ASSERT_TRUE(controller->HaveAuth());
   ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
-                    &request, CompletionOnceCallback(), dummy_log));
+                    &request, ssl_info, CompletionOnceCallback(), dummy_log));
 
   ASSERT_EQ(OK, controller->HandleAuthChallenge(continuation_headers, ssl_info,
                                                 false, false, dummy_log));
@@ -433,7 +435,7 @@
   controller->ResetAuth(AuthCredentials(u"user", u"pass"));
   ASSERT_TRUE(controller->HaveAuth());
   ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
-                    &request, CompletionOnceCallback(), dummy_log));
+                    &request, ssl_info1, CompletionOnceCallback(), dummy_log));
 
   ASSERT_EQ(OK, controller->HandleAuthChallenge(continuation_headers, ssl_info2,
                                                 false, false, dummy_log));
@@ -467,4 +469,124 @@
       url::SchemeHostPort(GURL("https://proxy.example.com:8080"))));
 }
 
+// Tests that a connection-based auth handler is dropped, without a token
+// being generated from it, when the connection is replaced with one that
+// presents a different server certificate before the next request is sent.
+// The scheme cannot be continued preemptively, so authentication must
+// restart with the next challenge.
+TEST(HttpAuthControllerTest,
+     ConnectionBasedHandlerDroppedOnCertificateChangeBeforeGenerate) {
+  NetLogWithSource dummy_log;
+  HttpRequestInfo request;
+  request.method = "GET";
+  request.url = GURL("https://example.com");
+
+  scoped_refptr<HttpResponseHeaders> initial_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock\r\n"
+                        "\r\n"));
+
+  scoped_refptr<X509Certificate> cert1 =
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/net/http/http_auth_controller_unittest.cc b/net/http/http_auth_controller_unittest.cc
index 856ddd21..9e576967 100644
--- a/net/http/http_auth_controller_unittest.cc
+++ b/net/http/http_auth_controller_unittest.cc
@@ -96,8 +96,8 @@
   TestCompletionCallback callback;
   EXPECT_EQ(
       (run_mode == RUN_HANDLER_ASYNC) ? ERR_IO_PENDING : expected_controller_rv,
-      controller->MaybeGenerateAuthToken(&request, callback.callback(),
-                                         net_log));
+      controller->MaybeGenerateAuthToken(&request, null_ssl_info,
+                                         callback.callback(), net_log));
   if (run_mode == RUN_HANDLER_ASYNC)
     EXPECT_EQ(expected_controller_rv, callback.WaitForResult());
   EXPECT_EQ((scheme_state == SCHEME_IS_DISABLED),
@@ -285,8 +285,9 @@
   EXPECT_TRUE(controller->HaveAuth());
 
   // Should only succeed if we are using the AUTH_SCHEME_MOCK MockHandler.
-  EXPECT_EQ(OK, controller->MaybeGenerateAuthToken(
-                    &request, CompletionOnceCallback(), dummy_log));
+  EXPECT_EQ(OK,
+            controller->MaybeGenerateAuthToken(
+                &request, null_ssl_info, CompletionOnceCallback(), dummy_log));
   controller->AddAuthorizationHeader(&request_headers);
 
   // Once a token is generated, simulate the receipt of a server response
@@ -300,8 +301,9 @@
   EXPECT_FALSE(controller->IsAuthSchemeDisabled(HttpAuth::AUTH_SCHEME_BASIC));
 
   // Should only succeed if we are using the AUTH_SCHEME_BASIC MockHandler.
-  EXPECT_EQ(OK, controller->MaybeGenerateAuthToken(
-                    &request, CompletionOnceCallback(), dummy_log));
+  EXPECT_EQ(OK,
+            controller->MaybeGenerateAuthToken(
+                &request, null_ssl_info, CompletionOnceCallback(), dummy_log));
 }
 
 // Tests that a connection-based auth handler is reused when a subsequent
@@ -359,7 +361,7 @@
   controller->ResetAuth(AuthCredentials(u"user", u"pass"));
   ASSERT_TRUE(controller->HaveAuth());
   ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
-                    &request, CompletionOnceCallback(), dummy_log));
+                    &request, ssl_info, CompletionOnceCallback(), dummy_log));
 
   ASSERT_EQ(OK, controller->HandleAuthChallenge(continuation_headers, ssl_info,
                                                 false, false, dummy_log));
@@ -433,7 +435,7 @@
   controller->ResetAuth(AuthCredentials(u"user", u"pass"));
   ASSERT_TRUE(controller->HaveAuth());
   ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
-                    &request, CompletionOnceCallback(), dummy_log));
+                    &request, ssl_info1, CompletionOnceCallback(), dummy_log));
 
   ASSERT_EQ(OK, controller->HandleAuthChallenge(continuation_headers, ssl_info2,
                                                 false, false, dummy_log));
@@ -467,4 +469,124 @@
       url::SchemeHostPort(GURL("https://proxy.example.com:8080"))));
 }
 
+// Tests that a connection-based auth handler is dropped, without a token
+// being generated from it, when the connection is replaced with one that
+// presents a different server certificate before the next request is sent.
+// The scheme cannot be continued preemptively, so authentication must
+// restart with the next challenge.
+TEST(HttpAuthControllerTest,
+     ConnectionBasedHandlerDroppedOnCertificateChangeBeforeGenerate) {
+  NetLogWithSource dummy_log;
+  HttpRequestInfo request;
+  request.method = "GET";
+  request.url = GURL("https://example.com");
+
+  scoped_refptr<HttpResponseHeaders> initial_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock\r\n"
+                        "\r\n"));
+
+  scoped_refptr<X509Certificate> cert1 =
+      ImportCertFromFile(GetTestCertsDirectory(), "ok_cert.pem");
+  ASSERT_TRUE(cert1);
+  scoped_refptr<X509Certificate> cert2 =
+      ImportCertFromFile(GetTestCertsDirectory(), "wildcard.pem");
+  ASSERT_TRUE(cert2);
+  ASSERT_FALSE(cert1->EqualsExcludingChain(cert2.get()));
+
+  SSLInfo ssl_info1;
+  ssl_info1.cert = cert1;
+  SSLInfo ssl_info2;
+  ssl_info2.cert = cert2;
+
+  auto host_resolver = std::make_unique<MockHostResolver>();
+  HttpAuthCache dummy_auth_cache(
+      false /* key_server_entries_by_network_anonymization_key */);
+  HttpAuthHandlerMock::Factory auth_handler_factory;
+  auth_handler_factory.set_do_init_from_challenge(true);
+
+  auto handler = std::make_unique<HttpAuthHandlerMock>();
+  handler->set_connection_based(true);
+  auth_handler_factory.AddMockHandler(std::move(handler),
+                                      HttpAuth::AUTH_SERVER);
+
+  scoped_refptr<HttpAuthController> controller(
+      base::MakeRefCounted<HttpAuthController>(
+          HttpAuth::AUTH_SERVER, GURL("https://example.com"),
+          NetworkAnonymizationKey(), &dummy_auth_cache, &auth_handler_factory,
+          host_resolver.get()));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(initial_headers, ssl_info1,
+                                                false, false, dummy_log));
+  ASSERT_TRUE(controller->HaveAuthHandler());
+  controller->ResetAuth(AuthCredentials(u"user", u"pass"));
+  ASSERT_TRUE(controller->HaveAuth());
+
+  // The connection was replaced with one that presents a different
+  // certificate, so no token may be generated from the handler created on
+  // the original connection.
+  ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
+                    &request, ssl_info2, CompletionOnceCallback(), dummy_log));
+  EXPECT_FALSE(controller->HaveAuthHandler());
+  EXPECT_FALSE(controller->HaveAuth());
+}
+
+// Tests that a handler that is not connection-based keeps generating tokens
+// when the connection is replaced with one that presents a different server
+// certificate. Such handlers derive no state from the connection.
+TEST(HttpAuthControllerTest,
+     NonConnectionBasedHandlerKeptOnCertificateChangeBeforeGenerate) {
+  NetLogWithSource dummy_log;
+  HttpRequestInfo request;
+  request.method = "GET";
+  request.url = GURL("https://example.com");
+
+  HttpRequestHeaders request_headers;
+  scoped_refptr<HttpResponseHeaders> initial_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock\r\n"
+                        "\r\n"));
+
+  scoped_refptr<X509Certificate> cert1 =
+      ImportCertFromFile(GetTestCertsDirectory(), "ok_cert.pem");
+  ASSERT_TRUE(cert1);
+  scoped_refptr<X509Certificate> cert2 =
+      ImportCertFromFile(GetTestCertsDirectory(), "wildcard.pem");
+  ASSERT_TRUE(cert2);
+  ASSERT_FALSE(cert1->EqualsExcludingChain(cert2.get()));
+
+  SSLInfo ssl_info1;
+  ssl_info1.cert = cert1;
+  SSLInfo ssl_info2;
+  ssl_info2.cert = cert2;
+
+  auto host_resolver = std::make_unique<MockHostResolver>();
+  HttpAuthCache dummy_auth_cache(
+      false /* key_server_entries_by_network_anonymization_key */);
+  HttpAuthHandlerMock::Factory auth_handler_factory;
+  auth_handler_factory.set_do_init_from_challenge(true);
+
+  auth_handler_factory.AddMockHandler(std::make_unique<HttpAuthHandlerMock>(),
+                                      HttpAuth::AUTH_SERVER);
+
+  scoped_refptr<HttpAuthController> controller(
+      base::MakeRefCounted<HttpAuthController>(
+          HttpAuth::AUTH_SERVER, GURL("https://example.com"),
+          NetworkAnonymizationKey(), &dummy_auth_cache, &auth_handler_factory,
+          host_resolver.get()));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(initial_headers, ssl_info1,
+                                                false, false, dummy_log));
+  ASSERT_TRUE(controller->HaveAuthHandler());
+  controller->ResetAuth(AuthCredentials(u"user", u"pass"));
+  ASSERT_TRUE(controller->HaveAuth());
+
+  ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
+                    &request, ssl_info2, CompletionOnceCallback(), dummy_log));
+  EXPECT_TRUE(controller->HaveAuthHandler());
+  EXPECT_TRUE(controller->HaveAuth());
+  controller->AddAuthorizationHeader(&request_headers);
+  EXPECT_TRUE(request_headers.HasHeader(HttpRequestHeaders::kAuthorization));
+}
+
 }  // namespace net
diff --git a/net/http/http_network_transaction_unittest.cc b/net/http/http_network_transaction_unittest.cc
index 1493565..b606a96 100644
--- a/net/http/http_network_transaction_unittest.cc
+++ b/net/http/http_network_transaction_unittest.cc
@@ -920,13 +920,16 @@
   return true;
 }
 
+// `expected_challenger` defaults to the HTTP proxy that most tests use. Tests
+// that authenticate to an HTTPS proxy pass their own value.
 bool CheckNTLMProxyAuth(
-    const std::optional<AuthChallengeInfo>& auth_challenge) {
+    const std::optional<AuthChallengeInfo>& auth_challenge,
+    std::string_view expected_challenger = "http://server") {
   if (!auth_challenge) {
     return false;
   }
   EXPECT_TRUE(auth_challenge->is_proxy);
-  EXPECT_EQ("http://server", auth_challenge->challenger.Serialize());
+  EXPECT_EQ(expected_challenger, auth_challenge->challenger.Serialize());
   EXPECT_EQ(std::string(), auth_challenge->realm);
   EXPECT_EQ(kNtlmAuthScheme, auth_challenge->scheme);
   return true;
@@ -12475,6 +12478,575 @@
   EXPECT_TRUE(data3.AllWriteDataConsumed());
 }
 
+// The server closes the connection on the challenge response, so the
+// authenticate message is sent on a replacement connection. As long as the
+// replacement connection presents the same server certificate, the handshake
+// continues where it left off.
+TEST_P(HttpNetworkTransactionTest, NTLMAuthV2ContinuesAcrossReconnect) {
+  HttpRequestInfo request;
+  request.method = "GET";
+  request.url = GURL("https://server/kids/login.aspx");
+  request.traffic_annotation =
+      MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS);
+  request.load_flags = LOAD_DO_NOT_USE_EMBEDDED_IDENTITY;
+
+  HttpAuthNtlmMechanism::ScopedProcSetter proc_setter(
+      MockGetMSTime, MockGenerateRandom, MockGetHostName);
+  std::unique_ptr<HttpNetworkSession> session(CreateSession(&session_deps_));
+
+  // Generate the NTLM messages based on known test data.
+  std::string negotiate_msg = base::Base64Encode(std::string_view(
+      reinterpret_cast<const char*>(ntlm::test::kExpectedNegotiateMsg),
+      std::size(ntlm::test::kExpectedNegotiateMsg)));
+  std::string challenge_msg = base::Base64Encode(std::string_view(
+      reinterpret_cast<const char*>(ntlm::test::kChallengeMsgFromSpecV2),
+      std::size(ntlm::test::kChallengeMsgFromSpecV2)));
+  std::string authenticate_msg = base::Base64Encode(std::string_view(
+      reinterpret_cast<const char*>(
+          ntlm::test::kExpectedAuthenticateMsgEmptyChannelBindingsV2),
+      std::size(ntlm::test::kExpectedAuthenticateMsgEmptyChannelBindingsV2)));
+
+  std::string request_write2 =
+      base::StrCat({"GET /kids/login.aspx HTTP/1.1\r\n"
+                    "Host: server\r\n"
+                    "Connection: keep-alive\r\n"
+                    "Authorization: NTLM ",
+                    negotiate_msg, "\r\n\r\n"});
+
+  std::string response_read2 =
+      base::StrCat({"HTTP/1.1 401 Access Denied\r\n"
+                    "WWW-Authenticate: NTLM ",
+                    challenge_msg,
+                    "\r\n"
+                    "Connection: close\r\n"
+                    "Content-Length: 42\r\n"
+                    "Content-Type: text/html\r\n\r\n"});
+
+  std::string request_write3 =
+      base::StrCat({"GET /kids/login.aspx HTTP/1.1\r\n"
+                    "Host: server\r\n"
+                    "Connection: keep-alive\r\n"
+                    "Authorization: NTLM ",
+                    authenticate_msg, "\r\n\r\n"});
+
+  MockWrite data_writes1[] = {
+      MockWrite(ASYNC, 0,
+                "GET /kids/login.aspx HTTP/1.1\r\n"
+                "Host: server\r\n"
+                "Connection: keep-alive\r\n\r\n"),
+  };
+
+  MockRead data_reads1[] = {
+      MockRead(ASYNC, 1,
+               "HTTP/1.1 401 Access Denied\r\n"
+               "WWW-Authenticate: NTLM\r\n"
+               "Connection: close\r\n"
+               "Content-Length: 42\r\n"
+               "Content-Type: text/html\r\n\r\n"),
+      // Missing content -- won't matter, as connection will be reset.
+  };
+
+  MockWrite data_writes2[] = {
+      // After restarting with a null identity, this is the
+      // request we should be issuing -- the final header line contains a Type
+      // 1 message.
+      MockWrite(ASYNC, 0, request_write2),
+  };
+
+  MockRead data_reads2[] = {
+      // The origin server responds with a Type 2 message, but closes the
+      // connection, so the Type 3 message has to be sent on a fresh one.
+      MockRead(ASYNC, 1, response_read2),
+      // Missing content -- won't matter, as connection will be reset.
+  };
+
+  MockWrite data_writes3[] = {
+      // The Type 3 message is sent on the replacement connection.
+      MockWrite(ASYNC, 0, request_write3),
+  };
+
+  MockRead data_reads3[] = {
+      // The desired content.
+      MockRead(ASYNC, 1,
+               "HTTP/1.1 200 OK\r\n"
... (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.