Medium watch CVSS 4.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProtection mechanism failure in HttpsUpgrades
DescriptionProtection mechanism failure in HttpsUpgrades
ComponentHttpsUpgrades
Bug ClassLogic Error
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.
Tracker502344135
Fix commit4407087b8829 (chromium/src) +55/-1
CWECWE-693
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-14 (per vendor advisory)
Fix landed2026-07-14 (public git)
Disclosed2026-08-25
Patch gap42 days — fix public in source before the advisory

Background

HTTPS-Upgrades
Chrome automatically tries main-frame http:// navigations over HTTPS and falls back to HTTP if that fails. HttpsUpgradesInterceptor implements this in chrome/browser/ssl/https_upgrades_interceptor.cc.
Exempted navigations
when the strict HTTPS-First interstitial is not enabled (!IsStrictInterstitialEnabled), ShouldExcludeNavigationFromUpgrades exempts some navigations, for example URLs the user explicitly typed with http:// and captive-portal login pages, so they load over plain HTTP.
HTTP allowlist
SSLHostStateDelegate::AllowHttpForHost persistently records a host per storage_partition as allowed over HTTP. Later navigations to that host are not upgraded while the entry lasts (checked via IsHttpAllowedForHost).
Per-hop interception
the commit explains that HttpsUpgradesInterceptor::MaybeCreateLoader() runs once for the initial request and again for every redirect hop. tentative_resource_request.navigation_redirect_chain holds the URLs seen so far.

Root Cause Analysis

In the exempted-navigation branch, the interceptor called state->AllowHttpForHost(tentative_resource_request.url.GetHost(), storage_partition) whenever state was non-null. Because the interceptor runs for every redirect hop, it allowlisted the host of every hop and not only the host the user actually chose. A server answering the first (exempted, plain-HTTP) request could therefore redirect through any number of other hosts and get each of them persistently added to the HTTP allowlist, turning off HTTPS-Upgrades for those hosts in later, unrelated navigations. The violated invariant is that the persistent HTTP exemption must reflect a user decision about one host, not server-controlled redirect targets.

The fix tightens the condition to state && tentative_resource_request.navigation_redirect_chain.size() <= 1, so only the initial request URL is allowlisted. Redirect hops of the exempted navigation still load over HTTP for that navigation, but they no longer add their hosts to the persistent list.

Key insight
A per-navigation exemption granted by user intent was turned into a persistent per-host allowlist entry for every server-chosen redirect hop. The fix limits the persistent write to the first entry in the redirect chain.

Attack Path

  1. Exempted first hop the user explicitly types an http:// URL for a host the attacker controls, or visits a captive-portal login page, with the strict HTTPS-First interstitial not enabled. This navigation is exempted from upgrades and its host is allowlisted, which is intended.
  2. Server-side redirect chain the first-hop server answers with redirects to other hosts chosen by the attacker, as the regression test does with foo.com → bar.com → baz.com. Because the first hop is plain HTTP, an on-path network attacker could also inject these redirects (inferred).
  3. Each hop allowlisted MaybeCreateLoader() runs again for each redirect hop, still treats the navigation as exempted, and before the fix called AllowHttpForHost for every hop’s host.
  4. Persistent downgrade later navigations to those allowlisted hosts are not upgraded to HTTPS while the entry lasts, so a network attacker can observe or tamper with those plain-HTTP loads (inferred).

Impact Assessment

The attacker can silently disable Chrome’s HTTPS-Upgrades protection for arbitrary hosts in the victim’s profile and storage partition. This undoes a protection mechanism, as the advisory’s “Protection mechanism failure” wording says, and makes later visits to those hosts eligible for plain-HTTP loading. Preconditions are user interaction (explicitly typing an http:// URL to an attacker-controlled host, or a captive-portal flow), the strict HTTPS-First interstitial being off, and, to profit from the downgrade, a network position on the later HTTP traffic (inferred). The bug is in browser-process navigation policy and gives no memory-safety or code-execution primitive. No exploited-in-the-wild status is given.

Changed Functions

FunctionChangeNotes
if
chrome/browser/ssl/https_upgrades_interceptor.cc
modified

Files Changed

  • chrome/browser/ssl/https_upgrades_interceptor.cc
  • chrome/browser/ssl/https_upgrades_browsertest.cc

Audit Directions

  • Per-hop hooks with persistent side effects
    look for other URLLoaderRequestInterceptor or NavigationThrottle code that runs on WillRedirectRequest/each hop and writes durable state (allowlists, HSTS-like exceptions, permission or interstitial bypass decisions) keyed by the current URL instead of the user-chosen one.
  • User-intent exemptions over redirects
    review exemptions derived from typed URLs, omnibox transitions or captive-portal detection to check that they apply only to the host the user chose and do not carry over to server-controlled redirect targets.
  • Related HTTP allowlisting paths
    audit the other AllowHttpForHost callers (e.g. after an upgrade fallback or interstitial proceed) for the same “allowlist every hop” pattern.
From 4407087b8829dc0dca96f1d8c957b00a2e7431b4 Mon Sep 17 00:00:00 2001
From: Chris Thompson <cthomp@chromium.org>
Date: Tue, 14 Jul 2026 15:49:40 -0700
Subject: [PATCH] Only HTTP-allowlist the initial host of exempted navigations

HttpsUpgradesInterceptor::MaybeCreateLoader() runs once per redirect
hop. For navigations exempted from upgrades (typed http:// URLs and
captive-portal login pages) it was adding the current hop's host to the
persistent HTTP_ALLOWED list on every invocation, so a server
controlling the first hop could allowlist arbitrary redirect-target
hosts for the lifetime of the entry.

Restrict the allowlist write to the initial request URL only; redirect
hops of an exempted navigation still load over HTTP but no longer add
their hosts to the persistent allowlist.

TAG=agy
CONV=a47522ad-72e5-4f49-9a02-96961f6324e5

Bug: 502344135
Change-Id: Ifb62ec8a705fce3e12ea266b607a4a9e1bd93962
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8094102
Commit-Queue: Chris Thompson <cthomp@chromium.org>
Reviewed-by: Mustafa Emre Acer <meacer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1662228}
---
diff --git a/chrome/browser/ssl/https_upgrades_interceptor.cc b/chrome/browser/ssl/https_upgrades_interceptor.cc
index 1cfb2b85..6922671 100644
--- a/chrome/browser/ssl/https_upgrades_interceptor.cc
+++ b/chrome/browser/ssl/https_upgrades_interceptor.cc
@@ -364,7 +364,11 @@
   // portal hostnames.
   if (!IsStrictInterstitialEnabled(*interstitial_state_) &&
       ShouldExcludeNavigationFromUpgrades(navigation_ui_data_, web_contents)) {
-    if (state) {
+    // Only allowlist the initial host of the navigation. Server-side redirect
+    // targets are not chosen by the user and shouldn't be persistently
+    // allowlisted.
+    if (state &&
+        tentative_resource_request.navigation_redirect_chain.size() <= 1) {
       state->AllowHttpForHost(tentative_resource_request.url.GetHost(),
                               storage_partition);
     }
diff --git a/chrome/browser/ssl/https_upgrades_browsertest.cc b/chrome/browser/ssl/https_upgrades_browsertest.cc
index 2893345..11096ee 100644
--- a/chrome/browser/ssl/https_upgrades_browsertest.cc
+++ b/chrome/browser/ssl/https_upgrades_browsertest.cc
@@ -3656,6 +3656,56 @@
       contents->GetPrimaryMainFrame()->GetStoragePartition()));
 }
 
+// Tests that when a URL typed with an explicit http:// scheme is redirected by
+// the server to other hosts, only the host of the originally typed URL is
+// added to the allowlist. The exemption applies to the navigation as a whole,
+// but server-chosen redirect targets must not be persistently allowlisted.
+IN_PROC_BROWSER_TEST_P(HttpsUpgradesBrowserTest,
+                       URLsTypedWithHttpSchemeAllowlistOnlyInitialHost) {
+  if (IsHttpsFirstModeInterstitialEnabledAcrossSites()) {
+    return;
+  }
+  GURL final_url = http_server()->GetURL("baz.com", "/simple.html");
+  GURL hop_url = http_server()->GetURL(
+      "bar.com", "/server-redirect?" + final_url.spec());
+  GURL initial_url = http_server()->GetURL(
+      "foo.com", "/server-redirect?" + hop_url.spec());
+  auto* contents = GetBrowser()->tab_strip_model()->GetActiveWebContents();
+  OmniboxClient* omnibox_client = BrowserWindow::FromBrowser(GetBrowser())
+                                      ->GetLocationBar()
+                                      ->GetOmniboxController()
+                                      ->client();
+
+  Profile* profile = Profile::FromBrowserContext(contents->GetBrowserContext());
+  content::SSLHostStateDelegate* state = profile->GetSSLHostStateDelegate();
+  auto* storage_partition =
+      contents->GetPrimaryMainFrame()->GetStoragePartition();
+
+  // None of the hosts should be in the allowlist yet.
+  EXPECT_FALSE(state->IsHttpAllowedForHost("foo.com", storage_partition));
+  EXPECT_FALSE(state->IsHttpAllowedForHost("bar.com", storage_partition));
+  EXPECT_FALSE(state->IsHttpAllowedForHost("baz.com", storage_partition));
+
+  // Simulate the full URL was typed with an http scheme. The server redirects
+  // through bar.com and baz.com.
+  content::TestNavigationObserver nav_observer(contents, 1);
+  omnibox_client->OnAutocompleteAccept(
+      initial_url, nullptr, WindowOpenDisposition::CURRENT_TAB,
+      ui::PAGE_TRANSITION_TYPED, AutocompleteMatchType::URL_WHAT_YOU_TYPED,
+      base::TimeTicks(), false, true, std::u16string(), AutocompleteMatch(),
+      AutocompleteMatch());
+  nav_observer.Wait();
+
+  // None of the hops should have been upgraded.
+  EXPECT_EQ(final_url, contents->GetLastCommittedURL());
+
+  // The host the user typed should be in the allowlist, but the
+  // server-selected redirect targets should not.
+  EXPECT_TRUE(state->IsHttpAllowedForHost("foo.com", storage_partition));
+  EXPECT_FALSE(state->IsHttpAllowedForHost("bar.com", storage_partition));
+  EXPECT_FALSE(state->IsHttpAllowedForHost("baz.com", storage_partition));
+}
+
 // Returns a URL loader interceptor that responds to HTTPS URLs with a timeout
 // error.
 std::unique_ptr<content::URLLoaderInterceptor> MakeTimeoutInterceptor() {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ssl/https_upgrades_browsertest.cc b/chrome/browser/ssl/https_upgrades_browsertest.cc
index 2893345..11096ee 100644
--- a/chrome/browser/ssl/https_upgrades_browsertest.cc
+++ b/chrome/browser/ssl/https_upgrades_browsertest.cc
@@ -3656,6 +3656,56 @@
       contents->GetPrimaryMainFrame()->GetStoragePartition()));
 }
 
+// Tests that when a URL typed with an explicit http:// scheme is redirected by
+// the server to other hosts, only the host of the originally typed URL is
+// added to the allowlist. The exemption applies to the navigation as a whole,
+// but server-chosen redirect targets must not be persistently allowlisted.
+IN_PROC_BROWSER_TEST_P(HttpsUpgradesBrowserTest,
+                       URLsTypedWithHttpSchemeAllowlistOnlyInitialHost) {
+  if (IsHttpsFirstModeInterstitialEnabledAcrossSites()) {
+    return;
+  }
+  GURL final_url = http_server()->GetURL("baz.com", "/simple.html");
+  GURL hop_url = http_server()->GetURL(
+      "bar.com", "/server-redirect?" + final_url.spec());
+  GURL initial_url = http_server()->GetURL(
+      "foo.com", "/server-redirect?" + hop_url.spec());
+  auto* contents = GetBrowser()->tab_strip_model()->GetActiveWebContents();
+  OmniboxClient* omnibox_client = BrowserWindow::FromBrowser(GetBrowser())
+                                      ->GetLocationBar()
+                                      ->GetOmniboxController()
+                                      ->client();
+
+  Profile* profile = Profile::FromBrowserContext(contents->GetBrowserContext());
+  content::SSLHostStateDelegate* state = profile->GetSSLHostStateDelegate();
+  auto* storage_partition =
+      contents->GetPrimaryMainFrame()->GetStoragePartition();
+
+  // None of the hosts should be in the allowlist yet.
+  EXPECT_FALSE(state->IsHttpAllowedForHost("foo.com", storage_partition));
+  EXPECT_FALSE(state->IsHttpAllowedForHost("bar.com", storage_partition));
+  EXPECT_FALSE(state->IsHttpAllowedForHost("baz.com", storage_partition));
+
+  // Simulate the full URL was typed with an http scheme. The server redirects
+  // through bar.com and baz.com.
+  content::TestNavigationObserver nav_observer(contents, 1);
+  omnibox_client->OnAutocompleteAccept(
+      initial_url, nullptr, WindowOpenDisposition::CURRENT_TAB,
+      ui::PAGE_TRANSITION_TYPED, AutocompleteMatchType::URL_WHAT_YOU_TYPED,
+      base::TimeTicks(), false, true, std::u16string(), AutocompleteMatch(),
+      AutocompleteMatch());
+  nav_observer.Wait();
+
+  // None of the hops should have been upgraded.
+  EXPECT_EQ(final_url, contents->GetLastCommittedURL());
+
+  // The host the user typed should be in the allowlist, but the
+  // server-selected redirect targets should not.
+  EXPECT_TRUE(state->IsHttpAllowedForHost("foo.com", storage_partition));
+  EXPECT_FALSE(state->IsHttpAllowedForHost("bar.com", storage_partition));
+  EXPECT_FALSE(state->IsHttpAllowedForHost("baz.com", storage_partition));
+}
+
 // Returns a URL loader interceptor that responds to HTTPS URLs with a timeout
 // error.
 std::unique_ptr<content::URLLoaderInterceptor> MakeTimeoutInterceptor() {
Loading diff…

Original Bug Report

reported by vm...@google.com

HTTPS-First Mode bypass via redirect chains poisoning the allowlist

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 without the Chrome Security team.

Overview: A discrepancy in how the force_no_https_upgrade flag is propagated causes the HttpsUpgradesInterceptor to be erroneously attached to navigations exempt from HTTPS upgrades. During redirect chains on these exempt navigations, the interceptor reuses the initial exemption state, incorrectly adding every target host in the chain to the 15-day HTTP allowlist. This allows a malicious captive portal or MITM attacker to silently disable HTTPS-First Mode protections for arbitrary domains.

Affected files:

  • chrome/browser/ssl/https_upgrades_interceptor.cc
  • chrome/browser/ui/browser_navigator_params_utils.cc
  • chrome/browser/chrome_content_browser_client.cc

Estimated timestamp from git blame: 2025-10-01

Summary

A potential logic error exists in the initialization of navigation parameters that causes HttpsUpgradesInterceptor to be attached to navigations that are meant to be explicitly excluded from HTTPS upgrades (such as captive portal probes or explicitly typed http:// URLs). Because interceptors are reused across redirect legs, the interceptor incorrectly applies the original navigation’s exemption status to every subsequent host in a redirect chain. This results in the persistent (15-day) allowlisting of arbitrary domains, silently bypassing HTTPS-First Mode (HFM) protections.

Technical Details

The root cause is a discrepancy in how force_no_https_upgrade is populated in chrome/browser/ui/browser_navigator_params_utils.cc:

  1. Missing Flag Propagation: When LoadURLParamsFromNavigateParams constructs the navigation parameters for a captive portal probe or an explicitly typed HTTP URL, it correctly sets force_no_https_upgrade_ = true inside the ChromeNavigationUIData object. However, it fails to set the corresponding force_no_https_upgrade boolean field in the content::NavigationController::LoadURLParams struct (which defaults to false).
  2. Erroneous Interceptor Creation: Because LoadURLParams.force_no_https_upgrade is false, NavigationControllerImpl initializes the NavigationRequest with force_no_https_upgrade_ = false. Consequently, ChromeContentBrowserClient::WillCreateURLLoaderRequestInterceptors erroneously creates an HttpsUpgradesInterceptor for the request.
  3. Initial Allowlist Write: Inside HttpsUpgradesInterceptor::MaybeCreateLoader, the code checks ShouldExcludeNavigationFromUpgrades. This function reads the correctly populated ChromeNavigationUIData and returns true. If Strict Mode is not enabled (e.g., the user is in Balanced Mode), the interceptor explicitly allowlists the initial host by calling StatefulSSLHostStateDelegate::AllowHttpForHost, writing a persistent ContentSettingsType::HTTP_ALLOWED entry valid for 15 days.
  4. Redirect Chain Poisoning: When the initial HTTP request receives a 30x redirect, NavigationURLLoaderImpl::Restart is called. Crucially, Restart reuses the exact same interceptor array and the same ChromeNavigationUIData instance for the new URL. The interceptor evaluates the new target host, sees the persistent exemption state in the UI data, and calls AllowHttpForHost again for the redirect target. This repeats for every hop in the chain.

Impact

A malicious captive portal or MITM attacker can intercept an exempt HTTP request (like a connectivity check) and respond with a redirect chain pointing to up to 20 victim domains (e.g., http://bank.example, http://email.example). This silently writes persistent HTTP exceptions for those domains into the user’s profile across all networks. For the next 15 days, any navigation to those domains will bypass HTTPS-First Mode upgrades, allowing the attacker to seamlessly impersonate the origins via plaintext HTTP.

Potential Reproduction Steps

Note: Our tooling agent does not have the ability to run code, so these are potential steps to reproduce the issue.

  1. Configure a Chrome profile with default HFM settings (e.g., Balanced mode enabled).
  2. As a MITM attacker or captive portal, intercept a plaintext HTTP connectivity probe (e.g., http://connectivitycheck.gstatic.com/generate_204).
  3. Respond with a chain of 30x redirects to target victim hosts: http://victim1.example -> http://victim2.example.
  4. End the redirect chain at the actual captive portal login page.
  5. Observe in chrome://settings/content/all or HostContentSettingsMap that victim1.example and victim2.example have been added to the HTTP allowlist with a 15-day expiration.
  6. On a trusted network, navigate to http://victim1.example and observe that no HFM interstitial is shown and the connection remains plaintext HTTP.

Suggested Fix

Update LoadURLParamsFromNavigateParams in chrome/browser/ui/browser_navigator_params_utils.cc to explicitly populate the force_no_https_upgrade field in content::NavigationController::LoadURLParams whenever it calculates the exemption for ChromeNavigationUIData.

  if (params->frame_tree_node_id.is_null()) {
    bool force_no_https_upgrade =
        params->url_typed_with_http_scheme ||
        params->captive_portal_window_type !=
            captive_portal::CaptivePortalWindowType::kNone;
    
    load_url_params.force_no_https_upgrade = force_no_https_upgrade; // <-- Add this line

    std::unique_ptr<ChromeNavigationUIData> navigation_ui_data =
        ChromeNavigationUIData::CreateForMainFrameNavigation(
            target_contents, params->is_using_https_as_default_scheme,
            force_no_https_upgrade);
    navigation_ui_data->set_navigation_initiated_from_sync(
        params->navigation_initiated_from_sync);
    load_url_params.navigation_ui_data = std::move(navigation_ui_data);
  }

This ensures that the NavigationRequest correctly understands the exemption, preventing the HttpsUpgradesInterceptor from being instantiated in the first place.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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