Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper certificate validation in Loader
DescriptionImproper certificate validation in Loader
ComponentLoader
Bug ClassLogic Error
Tracker540046516
Fix commite415ddae9e64 (chromium/src) +24/-52
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
for
content/browser/client_hints/client_hints_unittest.cc
modified

Files Changed

  • android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java
  • content/SECURITY.md
  • content/browser/client_hints/client_hints.cc
  • content/browser/client_hints/client_hints.h
  • content/browser/client_hints/client_hints_unittest.cc
From e415ddae9e64dcd0fec6fb0fa645d5beca0fbded Mon Sep 17 00:00:00 2001
From: Ari Chivukula <[email protected]>
Date: Wed, 02 Sep 2026 14:26:00 -0700
Subject: [PATCH] [TLS Cert] Allow updates when user bypasses error notice

Previously, in https://crrev.com/c/8164692, we made the call to block
client-hints storage when a TLS error was present. After discussion on
https://crrev.com/c/8307253 about the same issue for the clear-site-data
header we made the call to allow this and update the security.md file to
reflect this permissiveness for items besides the page cache (as, once
loaded, durable storage like cookies can be written anyway).

Fixed: 540046516, 553283471
Bug: 540681571
Change-Id: I32646e3340ba78cafb8847fecc93ec20c93b03da
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8315500
Commit-Queue: Kinuko Yasuda <[email protected]>
Auto-Submit: Ari Chivukula <[email protected]>
Reviewed-by: Kinuko Yasuda <[email protected]>
Reviewed-by: Peter Conn <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1691100}
---

diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java
index 66b300d..b16dc969 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java
@@ -96,10 +96,7 @@
     @Test
     @SmallTest
     @Feature({"AndroidWebView"})
-    @CommandLineFlags.Add({
-        "enable-features=BypassClientHintsSSLCertStatusForTest",
-        ContentSwitches.HOST_RESOLVER_RULES + "=MAP * 127.0.0.1"
-    })
+    @CommandLineFlags.Add({ContentSwitches.HOST_RESOLVER_RULES + "=MAP * 127.0.0.1"})
     public void testClientHintsDefault() throws Throwable {
         final TestAwContentsClient contentsClient = new TestAwContentsClient();
         final AwContents contents =
diff --git a/content/SECURITY.md b/content/SECURITY.md
index 62c2af5..f5e3402 100644
--- a/content/SECURITY.md
+++ b/content/SECURITY.md
@@ -149,6 +149,14 @@
 * **Safe Browsing:** Safe Browsing is not designed to be protected against a
   compromised renderer process; we can assume that Safe Browsing already failed
   to do its job if a renderer process is compromised.
+* **TLS Certificate Error:** If the user encounters a TLS certificate error
+  interstitial and allows the page load to move forward, it is generally
+  permissable for the HTTP Headers or JavaScript of that page to cause writes
+  (i.e., Set-Cookie, document.cookie, Clear-Site-Data, Accept-CH, localStorage).
+  Data not directly manageable by the origin va HTTP Headers or JavaScript may
+  be withheld due to sensitivity (i.e., passwords or autofill) or to prevent
+  caching of insecure resources (i.e., HTTP Cache), and is still in-scope.
+
 
 ## Mitigating Factors
 
diff --git a/content/browser/client_hints/client_hints.cc b/content/browser/client_hints/client_hints.cc
index ca7e3fd..b1778eb 100644
--- a/content/browser/client_hints/client_hints.cc
+++ b/content/browser/client_hints/client_hints.cc
@@ -980,15 +980,9 @@
       container_policy, /*ftn_for_devtools_override=*/frame_tree_node);
 }
 
-// Allow Android WebView tests to disable checking the cert status, which fails
-// when tests remap origins to localhost.
-BASE_FEATURE(kBypassClientHintsSSLCertStatusForTest,
-             base::FEATURE_DISABLED_BY_DEFAULT);
-
 std::optional<std::vector<WebClientHintsType>>
 ParseAndPersistAcceptCHForNavigation(
     const url::Origin& origin,
-    const std::optional<net::SSLInfo> ssl_info,
     const network::mojom::ParsedHeadersPtr& parsed_headers,
     const net::HttpResponseHeaders* response_headers,
     BrowserContext* context,
@@ -1004,11 +998,6 @@
   if (!IsValidURLForClientHints(origin))
     return std::nullopt;
 
-  if (ssl_info && net::IsCertStatusError(ssl_info->cert_status) &&
-      !base::FeatureList::IsEnabled(kBypassClientHintsSSLCertStatusForTest)) {
-    return std::nullopt;
-  }
-
   // Client hints should only be enabled when JavaScript is enabled. Platforms
   // which enable/disable JavaScript on a per-origin basis should implement
   // IsJavaScriptAllowed to check a given origin. Other platforms (Android
diff --git a/content/browser/client_hints/client_hints.h b/content/browser/client_hints/client_hints.h
index 41f3e37..d811d027 100644
--- a/content/browser/client_hints/client_hints.h
+++ b/content/browser/client_hints/client_hints.h
@@ -138,13 +138,9 @@
 // processes, but here, we just pass the HttpRequestHeaders to the
 // TrialTokenValidator library.  There is precedent for calling the
 // TrialTokenValidator from the browser process, see crrev.com/c/2142580.
-//
-// `ssl_info` is used to check for certificate errors. If there are any errors,
-// hints are not written or read.
 CONTENT_EXPORT std::optional<std::vector<network::mojom::WebClientHintsType>>
 ParseAndPersistAcceptCHForNavigation(
     const url::Origin& origin,
-    const std::optional<net::SSLInfo> ssl_info,
     const network::mojom::ParsedHeadersPtr& parsed_headers,
     const net::HttpResponseHeaders* response_headers,
     BrowserContext* context,
diff --git a/content/browser/client_hints/client_hints_unittest.cc b/content/browser/client_hints/client_hints_unittest.cc
index 12583451..f5607003 100644
--- a/content/browser/client_hints/client_hints_unittest.cc
+++ b/content/browser/client_hints/client_hints_unittest.cc
@@ -93,18 +93,15 @@
 
   std::optional<ClientHintsVector> ParseAndPersist(
       const GURL& url,
-      net::CertStatus cert_status,
       const net::HttpResponseHeaders* response_header,
       const std::string& accept_ch_str,
       FrameTreeNode* frame_tree_node,
       MockClientHintsControllerDelegate* delegate) {
     auto parsed_headers = network::mojom::ParsedHeaders::New();
     parsed_headers->accept_ch = network::ParseClientHintsHeader(accept_ch_str);
-    net::SSLInfo ssl_info;
-    ssl_info.cert_status = cert_status;
 
     return ParseAndPersistAcceptCHForNavigation(
-        url::Origin::Create(url), ssl_info, parsed_headers, response_header,
+        url::Origin::Create(url), parsed_headers, response_header,
         browser_context(), delegate, frame_tree_node);
   }
 
@@ -271,38 +268,25 @@
     std::string description;
     std::string accept_ch_str;
     raw_ptr<FrameTreeNode> frame_tree_node;
-    net::CertStatus cert_status;
     std::optional<ClientHintsVector> expect_hints;
     ClientHintsVector expect_commit_hints;
   } tests[] = {
-      {"Fail to persist due to cert status error",
-       "sec-ch-ua-platform, sec-ch-ua-bitness", main_frame_node,
-       net::CERT_STATUS_ALL_ERRORS, std::nullopt, ClientHintsVector{}},
       {"Persist hints for main frame", "sec-ch-ua-platform, sec-ch-ua-bitness",
        main_frame_node,
-       /*cert_status=*/0,
        std::make_optional(ClientHintsVector{WebClientHintsType::kUAPlatform,
                                             WebClientHintsType::kUABitness}),
        ClientHintsVector{WebClientHintsType::kUAPlatform,
                          WebClientHintsType::kUABitness}},
-      {"Fail to persist hints for sub frame",
-       "sec-ch-ua-platform, sec-ch-ua-bitness", sub_frame_node,
-       /*cert_status=*/0, std::nullopt,
-       ClientHintsVector{WebClientHintsType::kUAPlatform,
-                         WebClientHintsType::kUABitness}},
-      {"Fail to update due to cert status error",
-       all_non_origin_trial_hints_pair.first, main_frame_node,
-       net::CERT_STATUS_ALL_ERRORS, std::nullopt,
+      {"No persist hints for sub frame",
+       "sec-ch-ua-platform, sec-ch-ua-bitness", sub_frame_node, std::nullopt,
        ClientHintsVector{WebClientHintsType::kUAPlatform,
                          WebClientHintsType::kUABitness}},
       {"All client hints for main frame", all_non_origin_trial_hints_pair.first,
        main_frame_node,
-       /*cert_status=*/0,
        std::make_optional(all_non_origin_trial_hints_pair.second),
        all_non_origin_trial_hints_pair.second},
       {"All client hints for sub frame", all_non_origin_trial_hints_pair.first,
-       sub_frame_node, /*cert_status=*/0, std::nullopt,
-       all_non_origin_trial_hints_pair.second},
+       sub_frame_node, std::nullopt, all_non_origin_trial_hints_pair.second},
   };
 
   for (const auto& test : tests) {
@@ -310,8 +294,8 @@
         base::MakeRefCounted<net::HttpResponseHeaders>("HTTP/1.1 200 OK\n");
 
     auto actual_hints =
-        ParseAndPersist(url, test.cert_status, response_headers.get(),
-                        test.accept_ch_str, test.frame_tree_node, &delegate);
+        ParseAndPersist(url, response_headers.get(), test.accept_ch_str,
+                        test.frame_tree_node, &delegate);
     EXPECT_EQ(test.expect_hints, actual_hints)
         << "Test case [" << test.description << "]: expected hints "
         << HintsToString(test.expect_hints) << " but got "
@@ -350,9 +334,8 @@
 
   // We shouldn't parse accept-ch in subframe, it should not overwrite existing
   // hints.
-  auto actual_updated_hints =
-      ParseAndPersist(url, /*cert_status=*/0, response_headers.get(),
-                      accept_ch_str, sub_frame_node, &delegate);
+  auto actual_updated_hints = ParseAndPersist(
+      url, response_headers.get(), accept_ch_str, sub_frame_node, &delegate);
 
   EXPECT_EQ(std::nullopt, actual_updated_hints);
   blink::EnabledClientHints current_hints;
@@ -384,9 +367,8 @@
 
   // We shouldn't parse accept-ch in fenced frame, it should not overwrite
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java
index 66b300d..b16dc969 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/ClientHintsTest.java
@@ -96,10 +96,7 @@
     @Test
     @SmallTest
     @Feature({"AndroidWebView"})
-    @CommandLineFlags.Add({
-        "enable-features=BypassClientHintsSSLCertStatusForTest",
-        ContentSwitches.HOST_RESOLVER_RULES + "=MAP * 127.0.0.1"
-    })
+    @CommandLineFlags.Add({ContentSwitches.HOST_RESOLVER_RULES + "=MAP * 127.0.0.1"})
     public void testClientHintsDefault() throws Throwable {
         final TestAwContentsClient contentsClient = new TestAwContentsClient();
         final AwContents contents =
diff --git a/content/browser/client_hints/client_hints_unittest.cc b/content/browser/client_hints/client_hints_unittest.cc
index 12583451..f5607003 100644
--- a/content/browser/client_hints/client_hints_unittest.cc
+++ b/content/browser/client_hints/client_hints_unittest.cc
@@ -93,18 +93,15 @@
 
   std::optional<ClientHintsVector> ParseAndPersist(
       const GURL& url,
-      net::CertStatus cert_status,
       const net::HttpResponseHeaders* response_header,
       const std::string& accept_ch_str,
       FrameTreeNode* frame_tree_node,
       MockClientHintsControllerDelegate* delegate) {
     auto parsed_headers = network::mojom::ParsedHeaders::New();
     parsed_headers->accept_ch = network::ParseClientHintsHeader(accept_ch_str);
-    net::SSLInfo ssl_info;
-    ssl_info.cert_status = cert_status;
 
     return ParseAndPersistAcceptCHForNavigation(
-        url::Origin::Create(url), ssl_info, parsed_headers, response_header,
+        url::Origin::Create(url), parsed_headers, response_header,
         browser_context(), delegate, frame_tree_node);
   }
 
@@ -271,38 +268,25 @@
     std::string description;
     std::string accept_ch_str;
     raw_ptr<FrameTreeNode> frame_tree_node;
-    net::CertStatus cert_status;
     std::optional<ClientHintsVector> expect_hints;
     ClientHintsVector expect_commit_hints;
   } tests[] = {
-      {"Fail to persist due to cert status error",
-       "sec-ch-ua-platform, sec-ch-ua-bitness", main_frame_node,
-       net::CERT_STATUS_ALL_ERRORS, std::nullopt, ClientHintsVector{}},
       {"Persist hints for main frame", "sec-ch-ua-platform, sec-ch-ua-bitness",
        main_frame_node,
-       /*cert_status=*/0,
        std::make_optional(ClientHintsVector{WebClientHintsType::kUAPlatform,
                                             WebClientHintsType::kUABitness}),
        ClientHintsVector{WebClientHintsType::kUAPlatform,
                          WebClientHintsType::kUABitness}},
-      {"Fail to persist hints for sub frame",
-       "sec-ch-ua-platform, sec-ch-ua-bitness", sub_frame_node,
-       /*cert_status=*/0, std::nullopt,
-       ClientHintsVector{WebClientHintsType::kUAPlatform,
-                         WebClientHintsType::kUABitness}},
-      {"Fail to update due to cert status error",
-       all_non_origin_trial_hints_pair.first, main_frame_node,
-       net::CERT_STATUS_ALL_ERRORS, std::nullopt,
+      {"No persist hints for sub frame",
+       "sec-ch-ua-platform, sec-ch-ua-bitness", sub_frame_node, std::nullopt,
        ClientHintsVector{WebClientHintsType::kUAPlatform,
                          WebClientHintsType::kUABitness}},
       {"All client hints for main frame", all_non_origin_trial_hints_pair.first,
        main_frame_node,
-       /*cert_status=*/0,
        std::make_optional(all_non_origin_trial_hints_pair.second),
        all_non_origin_trial_hints_pair.second},
       {"All client hints for sub frame", all_non_origin_trial_hints_pair.first,
-       sub_frame_node, /*cert_status=*/0, std::nullopt,
-       all_non_origin_trial_hints_pair.second},
+       sub_frame_node, std::nullopt, all_non_origin_trial_hints_pair.second},
   };
 
   for (const auto& test : tests) {
@@ -310,8 +294,8 @@
         base::MakeRefCounted<net::HttpResponseHeaders>("HTTP/1.1 200 OK\n");
 
     auto actual_hints =
-        ParseAndPersist(url, test.cert_status, response_headers.get(),
-                        test.accept_ch_str, test.frame_tree_node, &delegate);
+        ParseAndPersist(url, response_headers.get(), test.accept_ch_str,
+                        test.frame_tree_node, &delegate);
     EXPECT_EQ(test.expect_hints, actual_hints)
         << "Test case [" << test.description << "]: expected hints "
         << HintsToString(test.expect_hints) << " but got "
@@ -350,9 +334,8 @@
 
   // We shouldn't parse accept-ch in subframe, it should not overwrite existing
   // hints.
-  auto actual_updated_hints =
-      ParseAndPersist(url, /*cert_status=*/0, response_headers.get(),
-                      accept_ch_str, sub_frame_node, &delegate);
+  auto actual_updated_hints = ParseAndPersist(
+      url, response_headers.get(), accept_ch_str, sub_frame_node, &delegate);
 
   EXPECT_EQ(std::nullopt, actual_updated_hints);
   blink::EnabledClientHints current_hints;
@@ -384,9 +367,8 @@
 
   // We shouldn't parse accept-ch in fenced frame, it should not overwrite
   // existing hints.
-  auto actual_updated_hints =
-      ParseAndPersist(url, /*cert_status=*/0, response_headers.get(),
-                      accept_ch_str, fenced_frame_node, &delegate);
+  auto actual_updated_hints = ParseAndPersist(
+      url, response_headers.get(), accept_ch_str, fenced_frame_node, &delegate);
 
   EXPECT_EQ(std::nullopt, actual_updated_hints);
   blink::EnabledClientHints current_hints;
Loading diff…

Original Bug Report

reported by [email protected]

Durable Client-Hints persistence on connections with TLS certificate errors

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential vulnerability in Chrome’s Client-Hints persistence logic allows an active on-path attacker to plant durable, high-entropy Client-Hints for a target origin over a connection with TLS certificate errors. Unlike other state-persisting security features (e.g., HSTS, Alt-Svc, DBSC), the Client-Hints persistence logic checks only the syntactic trustworthiness of the origin’s scheme, omitting a certificate status validation. This can cause high-entropy fingerprinting information to be volunteered on subsequent connections to the authentic origin.

Affected files:

  • content/browser/client_hints/client_hints.cc
  • content/browser/renderer_host/navigation_request.cc
  • content/browser/client_hints/critical_client_hints_throttle.cc

Estimated timestamp from git blame: 2020-06-30

Description

Root Cause

In content/browser/client_hints/client_hints.cc, ParseAndPersistAcceptCHForNavigation parses and persists origin-keyed Client-Hints from a navigation response header. However, its transport security verification is limited to checking whether the origin is potentially trustworthy via IsValidURLForClientHints:

// content/browser/client_hints/client_hints.cc
bool IsValidURLForClientHints(const url::Origin& origin) {
  return network::IsOriginPotentiallyTrustworthy(origin);
}

This check only validates structural aspects of the origin (such as whether the scheme is HTTPS). It does not consult the connection’s actual certificate status (cert_status or ssl_info).

This behavior diverges from other security mechanisms that write origin-keyed persistent state from response headers. For example, the following sibling handlers strictly discard headers if the connection has certificate errors:

  • HSTS (net/url_request/url_request_http_job.cc): IsCertStatusError(ssl_info.cert_status)
  • DBSC (net/url_request/url_request_http_job.cc): IsCertStatusError(ssl_info.cert_status)
  • Report-To (net/http/http_network_transaction.cc): IsCertStatusError(response_.ssl_info.cert_status)
  • NEL (net/http/http_network_transaction.cc): IsCertStatusError(response_.ssl_info.cert_status)
  • Alt-Svc (net/http/http_network_transaction.cc): !IsCertStatusError(response_.ssl_info.cert_status)

Persistence Sink

When client hints are parsed, PersistAcceptCH invokes delegate->PersistClientHints, which in Chrome is implemented via:

// components/client_hints/browser/client_hints.cc
content_settings::ContentSettingConstraints constraints;
constraints.set_session_model(content_settings::mojom::SessionModel::DURABLE);
settings_map_->SetWebsiteSettingDefaultScope(
    primary_url, GURL(), ContentSettingsType::CLIENT_HINTS,
    base::Value(std::move(client_hints_dictionary)), constraints);

Because the session model is set to DURABLE, this writes directly to the user profile’s preferences on disk, meaning the opt-in survives browser restarts.

Potential Steps to Trigger

Note: Our automated security review tooling does not currently have the capability to execute live code or run proofs of concept. The following are suggested, potential steps based on a static analysis of the codebase:

  1. An active on-path attacker (MITM) intercepts the network traffic of a victim who is navigating to https://victim.example.
  2. The attacker presents an invalid or self-signed certificate, causing Chrome to display an SSL/TLS certificate warning interstitial.
  3. The user clicks “Proceed” on the warning interstitial, registering a temporary certificate exception in SSLHostStateDelegate and triggering a reload.
  4. The connection is completed over the bypassed certificate. The attacker returns a 200 OK response with a malicious Accept-CH header containing a full set of high-entropy hints:
    Accept-CH: Sec-CH-UA-Full-Version-List, Sec-CH-UA-Arch, Sec-CH-UA-Bitness, Sec-CH-UA-Model, Sec-CH-UA-Platform-Version, Sec-CH-UA-WoW64, Device-Memory, Sec-CH-Device-Memory, Sec-CH-Viewport-Height, Sec-CH-DPR, Sec-CH-Prefers-Reduced-Motion, Save-Data
    
  5. NavigationRequest commits the navigation and invokes ParseAndPersistAcceptCHForNavigation. Because there is no validation of the connection’s certificate error status, the high-entropy Client-Hints preferences are successfully saved to disk as a durable content setting.
  6. The MITM is removed. When the victim later navigates to the authentic https://victim.example over a legitimate TLS connection, the browser retrieves the stored settings and automatically transmits the high-entropy client hints, defeating the privacy expectation that such hints are only volunteered on first-party opt-in.

Suggested Fix

Gate ParseAndPersistAcceptCHForNavigation (or its calling sites in NavigationRequest and CriticalClientHintsThrottle) to ensure that client hints are never persisted from connections with active certificate errors, mirroring the logic used in standard security headers:

if (net::IsCertStatusError(ssl_info.cert_status)) {
  return std::nullopt;
}

Evaluated with Chrome root at commit: 94d9235ebe3b7276e5284f0dc5d55577ff949908


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker