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
ImpactIncorrect reference resolution in Loader
DescriptionIncorrect reference resolution in Loader
ComponentLoader
Sub-componentLCPP
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.
Tracker502139081
Fix commitc943aa2ef9f1 (chromium/src) +195/-117
CWECWE-706
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-13 (per vendor advisory)
Fix landed2026-07-15 (public git)
Disclosed2026-08-25
Patch gap41 days — fix public in source before the advisory

Background

LCP Critical Path Predictor (LCPP)
a browser-side loading optimisation under chrome/browser/predictors/lcp_critical_path_predictor/ that learns, per site, data such as the LCP element locator, influencer scripts and fetched font/subresource URLs, and replays it as hints on later navigations.
LCPP key
the database key derived from a URL; the unit tests show it is built from host plus (optionally) a path segment, e.g. https://user:pass@a.test:99/foo;bar?q=a#ref → a.test/foo;bar, so the scheme is not part of the key.
`IsURLValidForLcpp`
the gate deciding whether a URL may be used to read or write LCPP data; it already rejects invalid URLs, empty hosts, localhost and overly long hosts.
Subresource prewarming / navigation-time hints
LoadingPredictor::MaybePrewarmResources and LoadingPredictorTabHelper use the learned data to start work (e.g. prewarming or hinting predicted resources) for a navigation before the page itself requests them.

Root Cause Analysis

Every LCPP gate accepted both schemes via SchemeIsHTTPOrHTTPS(), while the LCPP key is scheme-less, so http://a.test/... and https://a.test/... resolved to the same database entry. Data learned from a plain-HTTP load — whose content a network attacker can control — was therefore stored under the same key later consulted for the HTTPS version of the site, letting insecure content influence the predictor’s behaviour for a secure origin (an incorrect reference resolution across schemes). The same scheme-agnostic check existed in the initiator-origin validation path, in LoadingPredictor::MaybePrewarmResources, and in the navigation-hint path of loading_predictor_tab_helper.cc.

The fix tightens all four checks from SchemeIsHTTPOrHTTPS() to SchemeIs(url::kHttpsScheme), so HTTP pages neither write LCPP data nor receive LCPP-based prewarming or navigation hints; the updated LcppKeyTest.InvalidURLs now lists http://a.test, ws://a.test and wss://a.test as invalid.

Key insight
A scheme-less storage key was combined with gates that admitted both HTTP and HTTPS, so attacker-controllable HTTP loads could write state that HTTPS loads of the same host then trusted. Restricting every LCPP read/write/hint gate to HTTPS removes the insecure writer.

Attack Path

  1. Attacker controls an HTTP load of the target host A network-position attacker (or anyone able to serve http://target-host/... to the victim) gets the browser to load a plain-HTTP page for a host that the victim also visits over HTTPS; HSTS-protected hosts would not be loaded over HTTP.
  2. LCPP learns from the HTTP page Because IsURLValidForLcpp accepted http, the predictor records the attacker page’s LCP element locator, influencer scripts, font and subresource URLs under the scheme-less key shared with the HTTPS site.
  3. HTTPS navigation consults the poisoned entry On a later HTTPS navigation to the same host, LoadingPredictorTabHelper and LoadingPredictor::MaybePrewarmResources look up the same key and apply the attacker-supplied hints.
  4. Hints shape secure page loading The browser may prewarm or prioritise attacker-chosen URLs and element hints in the context of the HTTPS page, causing attacker-directed requests or loading behaviour (inferred); the patch does not show any direct script injection into the secure origin.

Impact Assessment

An attacker able to serve HTTP content for a host can poison browser-side loading-predictor state that is later applied to that host’s HTTPS navigations, crossing the HTTP/HTTPS trust boundary in the browser process’s predictor database. The advisory rates it as “Incorrect reference resolution in Loader” with no reported in-the-wild exploitation; consequences such as attacker-directed prefetch/prewarm requests, tracking, or performance degradation of the secure site are (inferred), and the bug depends on LCPP features being enabled (they may be field-trial or feature gated) and on the host being reachable over HTTP (no HSTS).

Changed Functions

FunctionChangeNotes
TEST
chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
modified
for
chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
modified
TEST_P
chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
modified

Files Changed

  • chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util.cc
  • chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
  • chrome/browser/predictors/loading_predictor.cc
  • chrome/browser/predictors/loading_predictor_tab_helper.cc
  • chrome/browser/predictors/loading_predictor_browsertest.cc

Audit Directions

  • Scheme-less cache keys
    Look for other browser-side learned or cached state keyed on host/path only (other chrome/browser/predictors tables, preconnect/prefetch predictors, site-engagement-style stores) where SchemeIsHTTPOrHTTPS() admits HTTP writers for data later consumed by HTTPS loads.
  • Read/write gate symmetry
    Verify that every gate guarding writes to a shared store is at least as strict as the gate guarding reads; a single permissive learning path is enough to poison data for stricter consumers.
  • Origin-to-URL conversions in predictors
    Where an url::Origin is converted with GetURL() and then validated (as in the initiator-origin path), check that scheme, opaque-origin and port handling match the URL-based path.
From c943aa2ef9f17f60658eaefcb736e89665d478dc Mon Sep 17 00:00:00 2001
From: Minoru Chikamune <chikamune@chromium.org>
Date: Wed, 15 Jul 2026 04:59:32 -0700
Subject: [PATCH] LCPP: Restrict LCPP database and navigation hints to HTTPS only

This CL restricts LCP Critical Path Predictor (LCPP) data collection,
LCPP-based subresource prewarming, and LCPP navigation time hints
strictly to HTTPS URLs. To support this change, LCPP unit tests and
browser tests are migrated to use HTTPS.

Fixed: 502139081
Change-Id: I589a45a7f3e63b7e46bd1fb006a499c187176c1d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8085005
Reviewed-by: Lingqi Chi <lingqi@chromium.org>
Reviewed-by: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Commit-Queue: Minoru Chikamune <chikamune@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1662520}
---
diff --git a/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util.cc b/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util.cc
index 3198fcca..6e30489b 100644
--- a/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util.cc
+++ b/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util.cc
@@ -1130,7 +1130,7 @@
 
 bool IsURLValidForLcpp(const GURL& url) {
   return url.is_valid() && !url.GetHost().empty() && !net::IsLocalhost(url) &&
-         url.SchemeIsHTTPOrHTTPS() &&
+         url.SchemeIs(url::kHttpsScheme) &&
          url.GetHost().size() <=
              ResourcePrefetchPredictorTables::kMaxStringLength;
 }
@@ -1147,7 +1147,7 @@
     GURL url = initiator_origin.GetURL();
     return !initiator_origin.opaque() && url.is_valid() &&
            !initiator_origin.host().empty() && !net::IsLocalhost(url) &&
-           url.SchemeIsHTTPOrHTTPS() &&
+           url.SchemeIs(url::kHttpsScheme) &&
            initiator_origin.host().size() <=
                ResourcePrefetchPredictorTables::kMaxStringLength;
   } else {
diff --git a/chrome/browser/predictors/loading_predictor.cc b/chrome/browser/predictors/loading_predictor.cc
index 843430f4..fa18f1a 100644
--- a/chrome/browser/predictors/loading_predictor.cc
+++ b/chrome/browser/predictors/loading_predictor.cc
@@ -539,7 +539,7 @@
   TRACE_EVENT("loading", "LoadingPredictor::MaybePrewarmResources");
 
   if (!top_frame_main_resource_url.is_valid() ||
-      !top_frame_main_resource_url.SchemeIsHTTPOrHTTPS()) {
+      !top_frame_main_resource_url.SchemeIs(url::kHttpsScheme)) {
     return;
   }
 
diff --git a/chrome/browser/predictors/loading_predictor_tab_helper.cc b/chrome/browser/predictors/loading_predictor_tab_helper.cc
index 29eaa5d..be0d889b 100644
--- a/chrome/browser/predictors/loading_predictor_tab_helper.cc
+++ b/chrome/browser/predictors/loading_predictor_tab_helper.cc
@@ -208,7 +208,8 @@
     return;
   }
   const GURL& navigation_url = navigation_handle.GetURL();
-  if (!navigation_url.is_valid() || !navigation_url.SchemeIsHTTPOrHTTPS()) {
+  if (!navigation_url.is_valid() ||
+      !navigation_url.SchemeIs(url::kHttpsScheme)) {
     return;
   }
 
diff --git a/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc b/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
index 95fb78f..043bf866 100644
--- a/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
+++ b/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
@@ -1224,16 +1224,19 @@
 TEST(LcppKeyTest, InvalidURLs) {
   const std::string invalid_urls[] = {
       // Invalid urls
-      "http://?k=v",
-      "http:://google.com",
-      "http://google.com:12three45",
+      "https://?k=v",
+      "https:://google.com",
+      "https://google.com:12three45",
       "://google.com",
       "path",
-      "",                  // Empty
-      "file://server:0",   // File
-      "ftp://server",      // Ftp
-      "http://localhost",  // Localhost
-      "http://127.0.0.1",  // Localhost
+      "",                   // Empty
+      "file://server:0",    // File
+      "ftp://server",       // Ftp
+      "http://a.test",      // Non-HTTPS scheme
+      "ws://a.test",        // Non-HTTPS scheme
+      "wss://a.test",       // Non-HTTPS scheme (we only support HTTPS)
+      "https://localhost",  // Localhost
+      "https://127.0.0.1",  // Localhost
       "https://example" +
           std::string(ResourcePrefetchPredictorTables::kMaxStringLength, 'a') +
           ".test/",  // Too long
@@ -1258,24 +1261,24 @@
   const std::string too_long_path =
       "/" + std::string(max_path_length + 1, 'c') + "/bar";
   const std::vector<std::pair<std::string, std::string>> url_keys = {
-      {"http://a.test", ""},
-      {"http://user:pass@a.test:99/foo;bar?q=a#ref", "/foo;bar"},
-      {"http://a.test/", ""},
-      {"http://a.test/foo.html", ""},
-      {"http://a.test/foo", "/foo"},
-      {"http://a.test/foo/", "/foo"},
-      {"http://a.test/foo/bar", "/foo"},
-      {"http://a.test/foo/bar/", "/foo"},
-      {"http://a.test/foo/bar/baz.com", "/foo"},
-      {"http://a.test/bar?q=c", "/bar"},
-      {"http://a.test/foo/bar?q=c", "/foo"},
-      {"http://a.test" + long_path, long_path},
-      {"http://a.test" + long_path + "/bar", long_path},
-      {"http://a.test" + long_path + "bar", ""},
+      {"https://a.test", ""},
+      {"https://user:pass@a.test:99/foo;bar?q=a#ref", "/foo;bar"},
+      {"https://a.test/", ""},
+      {"https://a.test/foo.html", ""},
+      {"https://a.test/foo", "/foo"},
+      {"https://a.test/foo/", "/foo"},
+      {"https://a.test/foo/bar", "/foo"},
+      {"https://a.test/foo/bar/", "/foo"},
+      {"https://a.test/foo/bar/baz.com", "/foo"},
+      {"https://a.test/bar?q=c", "/bar"},
+      {"https://a.test/foo/bar?q=c", "/foo"},
+      {"https://a.test" + long_path, long_path},
+      {"https://a.test" + long_path + "/bar", long_path},
+      {"https://a.test" + long_path + "bar", ""},
       // Too long path is ignored.
-      {"http://a.test" + too_long_path, ""},
+      {"https://a.test" + too_long_path, ""},
       // Invalid length path in subdirectory is also ignored.
-      {"http://a.test/bar" + too_long_path, "/bar"}};
+      {"https://a.test/bar" + too_long_path, "/bar"}};
 
   for (const auto& url_key : url_keys) {
     const GURL url(url_key.first);
@@ -1427,7 +1430,7 @@
       LearnElementLocator(GURL(url), "/#a", {});
       // Confirm 'url' was learned as 'key'.
       auto stat = lcpp_data_map_->GetLcppStat(/*initiator_origin=*/std::nullopt,
-                                              GURL("http://" + key));
+                                              GURL("https://" + key));
       EXPECT_TRUE(stat) << location.ToString() << url;
       LcppData expected;
       InitializeLcpElementLocatorBucket(expected, "/#a", ++frequency[key]);
@@ -1457,7 +1460,8 @@
   }
 
   static url::Origin CreateOrigin(const std::string& host_name) {
-    const url::Origin origin = url::Origin::Create(GURL("http://" + host_name));
+    const url::Origin origin =
+        url::Origin::Create(GURL("https://" + host_name));
     CHECK_EQ(origin.host(), host_name);
     return origin;
   }
@@ -1626,7 +1630,7 @@
   };
 
   for (int i = 0; i < 3; ++i) {
-    LearnElementLocator(GURL("http://a.test"), "/#a", {});
+    LearnElementLocator(GURL("https://a.test"), "/#a", {});
   }
   {
     LcppData data = CreateLcppData("a.test", 10);
@@ -1636,7 +1640,7 @@
   }
 
   for (int i = 0; i < 2; ++i) {
-    LearnElementLocator(GURL("http://a.test"), "/#b", {});
+    LearnElementLocator(GURL("https://a.test"), "/#b", {});
   }
   {
     LcppData data = CreateLcppData("a.test", 10);
@@ -1646,7 +1650,7 @@
     EXPECT_DOUBLE_EQ(5, SumOfElementLocatorFrequency(data));
   }
 
-  LearnElementLocator(GURL("http://a.test"), "/#c", {});
+  LearnElementLocator(GURL("https://a.test"), "/#c", {});
   {
     LcppData data = CreateLcppData("a.test", 10);
     InitializeLcpElementLocatorBucket(data, "/#a", 2.4);
@@ -1656,7 +1660,7 @@
     EXPECT_DOUBLE_EQ(5, SumOfElementLocatorFrequency(data));
   }
 
-  LearnElementLocator(GURL("http://a.test"), "/#d", {});
+  LearnElementLocator(GURL("https://a.test"), "/#d", {});
   {
     LcppData data = CreateLcppData("a.test", 10);
     InitializeLcpElementLocatorBucket(data, "/#a", 1.92);
@@ -1667,8 +1671,8 @@
   }
 
   for (int i = 0; i < 2; ++i) {
-    LearnElementLocator(GURL("http://a.test"), "/#c", {});
-    LearnElementLocator(GURL("http://a.test"), "/#d", {});
+    LearnElementLocator(GURL("https://a.test"), "/#c", {});
+    LearnElementLocator(GURL("https://a.test"), "/#d", {});
   }
   {
     LcppData data = CreateLcppData("a.test", 10);
@@ -1682,7 +1686,7 @@
   // Test that element locators and influencer scripts are independently learnt.
   for (int i = 0; i < 2; ++i) {
     LearnElementLocator(
-        GURL("http://a.test"), "",
+        GURL("https://a.test"), "",
         {GURL("https://a.test/script1.js"), GURL("https://a.test/script2.js")});
   }
   {
@@ -1702,7 +1706,7 @@
 
   for (int i = 0; i < 3; ++i) {
     LearnElementLocator(
-        GURL("http://a.test"), "",
+        GURL("https://a.test"), "",
         {GURL("https://a.test/script3.js"), GURL("https://a.test/script4.js")});
   }
   {
@@ -1731,7 +1735,7 @@
         data.lcpp_stat().fetched_font_url_stat());
   };
   for (int i = 0; i < 2; ++i) {
-    LearnFontUrls(GURL("http://example.test"),
+    LearnFontUrls(GURL("https://example.test"),
                   {
                       GURL("https://example.test/test.woff"),
                       GURL("https://example.test/test.ttf"),
@@ -1748,7 +1752,7 @@
     EXPECT_DOUBLE_EQ(4, SumOfFontUrlFrequency(data));
   }
   for (int i = 0; i < 3; ++i) {
-    LearnFontUrls(GURL("http://example.test"),
+    LearnFontUrls(GURL("https://example.test"),
                   {
                       GURL("https://example.org/test.otf"),
                       GURL("https://example.net/test.svg"),
@@ -1776,7 +1780,7 @@
       network::mojom::RequestDestination::kImage;
   const int32_t kImageValue = static_cast<int32_t>(kImage);
   const std::string kUrl = "example.test";
-  const GURL kGURL = GURL("http://" + kUrl);
+  const GURL kGURL = GURL("https://" + kUrl);
   const std::string kJpegA = "https://" + kUrl + "/a.jpeg";
   const std::string kJpegB = "https://" + kUrl + "/b.jpeg";
 
@@ -1864,7 +1868,7 @@
   }
 
   // Confirm that new learning process reset the corrupted data.
-  LearnElementLocator(GURL("http://a.test"), "/#a", {});
+  LearnElementLocator(GURL("https://a.test"), "/#a", {});
   {
     LcppData data = CreateLcppData("a.test", 10);
     InitializeLcpElementLocatorBucket(data, "/#a", 1);
@@ -1879,21 +1883,21 @@
   InitializeDB(config);
   EXPECT_TRUE(GetDataMap().empty());
 
-  const GURL url_a("http://a.test");
+  const GURL url_a("https://a.test");
   EXPECT_FALSE(GetLcppStat(url_a));
 
   LearnElementLocator(url_a, "/#a");
   EXPECT_TRUE(GetLcppStat(url_a));
 
-  const GURL url_b("http://b.test");
+  const GURL url_b("https://b.test");
   LearnElementLocator(url_b, "/#a");
-  const GURL url_c("http://c.test");
+  const GURL url_c("https://c.test");
   LearnElementLocator(url_c, "/#a");
   EXPECT_TRUE(GetLcppStat(url_a));
   EXPECT_TRUE(GetLcppStat(url_b));
   EXPECT_TRUE(GetLcppStat(url_c));
 
-  const GURL url_d("http://d.test");
+  const GURL url_d("https://d.test");
   LearnElementLocator(url_d, "/#a");
   EXPECT_TRUE(GetLcppStat(url_d));
   // Confirm first host is dropped.
@@ -1914,11 +1918,11 @@
   InitializeDB(config);
 
   const std::vector<std::pair<std::string, std::string>> url_keys = {
-      {"http://a.test", "a.test"},
-      {"http://a.test/", "a.test"},
-      {"http://a.test/foo", "a.test/foo"},
-      {"http://a.test/bar?q=c", "a.test/bar?q=c"},
-      {"http://user:pass@a.test:99/foo;bar?q=a#ref", "a.test/foo;bar?q=a#ref"},
+      {"https://a.test", "a.test"},
+      {"https://a.test/", "a.test"},
+      {"https://a.test/foo", "a.test/foo"},
+      {"https://a.test/bar?q=c", "a.test/bar?q=c"},
+      {"https://user:pass@a.test:99/foo;bar?q=a#ref", "a.test/foo;bar?q=a#ref"},
   };
 
   TestLearnLcppURL(url_keys);
@@ -1930,9 +1934,9 @@
   config.max_hosts_to_track_for_lcpp = 10u;
   InitializeDB(config);
 
-  const GURL url_a("http://a.test");
-  const GURL url_b("http://b.test");
-  const GURL url_c("http://c.test");
+  const GURL url_a("https://a.test");
+  const GURL url_b("https://b.test");
+  const GURL url_c("https://c.test");
 
   LearnElementLocator(url_a, "/#a");
   LearnElementLocator(url_b, "/#a");
@@ -2005,33 +2009,33 @@
   const std::string too_long_path =
       "/" + std::string(max_path_length + 1, 'c') + "/bar";
   const std::vector<std::pair<std::string, std::string>> url_keys = {
-      {"http://a.test", "a.test"},
-      {"http://user:pass@a.test:99/foo;bar?q=a#ref", "a.test/foo;bar"},
-      {"http://a.test/", "a.test"},
-      {"http://a.test/foo.html", "a.test"},
-      {"http://a.test/foo", "a.test/foo"},
-      {"http://a.test/foo/", "a.test/foo"},
-      {"http://a.test/foo/bar", "a.test/foo"},
-      {"http://a.test/foo/bar/", "a.test/foo"},
-      {"http://a.test/foo/bar/baz.com", "a.test/foo"},
-      {"http://a.test/bar?q=c", "a.test/bar"},
-      {"http://a.test/foo/bar?q=c", "a.test/foo"},
-      {"http://a.test" + long_path, "a.test" + long_path},
-      {"http://a.test" + long_path + "/bar", "a.test" + long_path},
-      {"http://a.test" + long_path + "bar", "a.test"},
-      {"http://" + long_host + "/bar", long_host + "/bar"},
+      {"https://a.test", "a.test"},
+      {"https://user:pass@a.test:99/foo;bar?q=a#ref", "a.test/foo;bar"},
+      {"https://a.test/", "a.test"},
+      {"https://a.test/foo.html", "a.test"},
+      {"https://a.test/foo", "a.test/foo"},
+      {"https://a.test/foo/", "a.test/foo"},
+      {"https://a.test/foo/bar", "a.test/foo"},
+      {"https://a.test/foo/bar/", "a.test/foo"},
+      {"https://a.test/foo/bar/baz.com", "a.test/foo"},
+      {"https://a.test/bar?q=c", "a.test/bar"},
+      {"https://a.test/foo/bar?q=c", "a.test/foo"},
+      {"https://a.test" + long_path, "a.test" + long_path},
+      {"https://a.test" + long_path + "/bar", "a.test" + long_path},
+      {"https://a.test" + long_path + "bar", "a.test"},
+      {"https://" + long_host + "/bar", long_host + "/bar"},
       // Both valid but if the concated key is too long, take only host.
-      {"http://" + long_host + long_path, long_host},
+      {"https://" + long_host + long_path, long_host},
       // Too long path is ignored.
-      {"http://a.test" + too_long_path, "a.test"},
+      {"https://a.test" + too_long_path, "a.test"},
       // Invalid length path in subdirectory is also ignored.
-      {"http://a.test/bar" + too_long_path, "a.test/bar"}};
+      {"https://a.test/bar" + too_long_path, "a.test/bar"}};
 
   TestLearnLcppURL(url_keys);
 }
 
 TEST_P(LcppMultipleKeyTest, ShouldNotLearnTooLongLocators) {
-  const GURL url("http://a.test/foo1");
+  const GURL url("https://a.test/foo1");
   LearnElementLocator(url, "/#lcp");
   const LcppStat expected = MakeLcppStatWithLCPElementLocator("/#lcp");
   EXPECT_EQ(*GetLcppStat(url), expected);
@@ -2045,11 +2049,11 @@
 TEST_P(LcppMultipleKeyTest, DeleteUrls) {
   const bool kIsDefault = std::get<1>(GetParam()) ==
                           blink::features::LcppMultipleKeyTypes::kDefault;
-  const GURL url_a_1("http://a.test");
-  const GURL url_a_2("http://a.test/foo");
-  const GURL url_a_3("http://a.test/bar");
-  const GURL url_b("http://b.test/baz");
-  const GURL url_c("http://c.test");
+  const GURL url_a_1("https://a.test");
+  const GURL url_a_2("https://a.test/foo");
+  const GURL url_a_3("https://a.test/bar");
+  const GURL url_b("https://b.test/baz");
+  const GURL url_c("https://c.test");
 
   const std::vector<GURL> urls = {url_a_1, url_a_2, url_a_3, url_b, url_c};
   for (const GURL& url : urls) {
@@ -2107,7 +2111,7 @@
   config.max_hosts_to_track_for_lcpp = 2u;
   InitializeDB(config);
 
-  const std::string host = "http://a.test";
+  const std::string host = "https://a.test";
   const GURL url_1(host + "/foo1");
   EXPECT_FALSE(GetLcppStat(url_1));
 
@@ -2182,7 +2186,7 @@
   config.lcpp_multiple_key_max_histogram_buckets = 3u;
   InitializeDB(config);
 
-  const std::string host = "http://a.test";
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc b/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
index 95fb78f..043bf866 100644
--- a/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
+++ b/chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc
@@ -1224,16 +1224,19 @@
 TEST(LcppKeyTest, InvalidURLs) {
   const std::string invalid_urls[] = {
       // Invalid urls
-      "http://?k=v",
-      "http:://google.com",
-      "http://google.com:12three45",
+      "https://?k=v",
+      "https:://google.com",
+      "https://google.com:12three45",
       "://google.com",
       "path",
-      "",                  // Empty
-      "file://server:0",   // File
-      "ftp://server",      // Ftp
-      "http://localhost",  // Localhost
-      "http://127.0.0.1",  // Localhost
+      "",                   // Empty
+      "file://server:0",    // File
+      "ftp://server",       // Ftp
+      "http://a.test",      // Non-HTTPS scheme
+      "ws://a.test",        // Non-HTTPS scheme
+      "wss://a.test",       // Non-HTTPS scheme (we only support HTTPS)
+      "https://localhost",  // Localhost
+      "https://127.0.0.1",  // Localhost
       "https://example" +
           std::string(ResourcePrefetchPredictorTables::kMaxStringLength, 'a') +
           ".test/",  // Too long
@@ -1258,24 +1261,24 @@
   const std::string too_long_path =
       "/" + std::string(max_path_length + 1, 'c') + "/bar";
   const std::vector<std::pair<std::string, std::string>> url_keys = {
-      {"http://a.test", ""},
-      {"http://user:pass@a.test:99/foo;bar?q=a#ref", "/foo;bar"},
-      {"http://a.test/", ""},
-      {"http://a.test/foo.html", ""},
-      {"http://a.test/foo", "/foo"},
-      {"http://a.test/foo/", "/foo"},
-      {"http://a.test/foo/bar", "/foo"},
-      {"http://a.test/foo/bar/", "/foo"},
-      {"http://a.test/foo/bar/baz.com", "/foo"},
-      {"http://a.test/bar?q=c", "/bar"},
-      {"http://a.test/foo/bar?q=c", "/foo"},
-      {"http://a.test" + long_path, long_path},
-      {"http://a.test" + long_path + "/bar", long_path},
-      {"http://a.test" + long_path + "bar", ""},
+      {"https://a.test", ""},
+      {"https://user:pass@a.test:99/foo;bar?q=a#ref", "/foo;bar"},
+      {"https://a.test/", ""},
+      {"https://a.test/foo.html", ""},
+      {"https://a.test/foo", "/foo"},
+      {"https://a.test/foo/", "/foo"},
+      {"https://a.test/foo/bar", "/foo"},
+      {"https://a.test/foo/bar/", "/foo"},
+      {"https://a.test/foo/bar/baz.com", "/foo"},
+      {"https://a.test/bar?q=c", "/bar"},
+      {"https://a.test/foo/bar?q=c", "/foo"},
+      {"https://a.test" + long_path, long_path},
+      {"https://a.test" + long_path + "/bar", long_path},
+      {"https://a.test" + long_path + "bar", ""},
       // Too long path is ignored.
-      {"http://a.test" + too_long_path, ""},
+      {"https://a.test" + too_long_path, ""},
       // Invalid length path in subdirectory is also ignored.
-      {"http://a.test/bar" + too_long_path, "/bar"}};
+      {"https://a.test/bar" + too_long_path, "/bar"}};
 
   for (const auto& url_key : url_keys) {
     const GURL url(url_key.first);
@@ -1427,7 +1430,7 @@
       LearnElementLocator(GURL(url), "/#a", {});
       // Confirm 'url' was learned as 'key'.
       auto stat = lcpp_data_map_->GetLcppStat(/*initiator_origin=*/std::nullopt,
-                                              GURL("http://" + key));
+                                              GURL("https://" + key));
       EXPECT_TRUE(stat) << location.ToString() << url;
       LcppData expected;
       InitializeLcpElementLocatorBucket(expected, "/#a", ++frequency[key]);
@@ -1457,7 +1460,8 @@
   }
 
   static url::Origin CreateOrigin(const std::string& host_name) {
-    const url::Origin origin = url::Origin::Create(GURL("http://" + host_name));
+    const url::Origin origin =
+        url::Origin::Create(GURL("https://" + host_name));
     CHECK_EQ(origin.host(), host_name);
     return origin;
   }
@@ -1626,7 +1630,7 @@
   };
 
   for (int i = 0; i < 3; ++i) {
-    LearnElementLocator(GURL("http://a.test"), "/#a", {});
+    LearnElementLocator(GURL("https://a.test"), "/#a", {});
   }
   {
     LcppData data = CreateLcppData("a.test", 10);
@@ -1636,7 +1640,7 @@
   }
 
   for (int i = 0; i < 2; ++i) {
-    LearnElementLocator(GURL("http://a.test"), "/#b", {});
+    LearnElementLocator(GURL("https://a.test"), "/#b", {});
   }
   {
     LcppData data = CreateLcppData("a.test", 10);
@@ -1646,7 +1650,7 @@
     EXPECT_DOUBLE_EQ(5, SumOfElementLocatorFrequency(data));
   }
 
-  LearnElementLocator(GURL("http://a.test"), "/#c", {});
+  LearnElementLocator(GURL("https://a.test"), "/#c", {});
   {
     LcppData data = CreateLcppData("a.test", 10);
     InitializeLcpElementLocatorBucket(data, "/#a", 2.4);
@@ -1656,7 +1660,7 @@
     EXPECT_DOUBLE_EQ(5, SumOfElementLocatorFrequency(data));
   }
 
-  LearnElementLocator(GURL("http://a.test"), "/#d", {});
+  LearnElementLocator(GURL("https://a.test"), "/#d", {});
   {
     LcppData data = CreateLcppData("a.test", 10);
     InitializeLcpElementLocatorBucket(data, "/#a", 1.92);
@@ -1667,8 +1671,8 @@
   }
 
   for (int i = 0; i < 2; ++i) {
-    LearnElementLocator(GURL("http://a.test"), "/#c", {});
-    LearnElementLocator(GURL("http://a.test"), "/#d", {});
+    LearnElementLocator(GURL("https://a.test"), "/#c", {});
+    LearnElementLocator(GURL("https://a.test"), "/#d", {});
   }
   {
     LcppData data = CreateLcppData("a.test", 10);
@@ -1682,7 +1686,7 @@
   // Test that element locators and influencer scripts are independently learnt.
   for (int i = 0; i < 2; ++i) {
     LearnElementLocator(
-        GURL("http://a.test"), "",
+        GURL("https://a.test"), "",
         {GURL("https://a.test/script1.js"), GURL("https://a.test/script2.js")});
   }
   {
@@ -1702,7 +1706,7 @@
 
   for (int i = 0; i < 3; ++i) {
     LearnElementLocator(
-        GURL("http://a.test"), "",
+        GURL("https://a.test"), "",
         {GURL("https://a.test/script3.js"), GURL("https://a.test/script4.js")});
   }
   {
@@ -1731,7 +1735,7 @@
         data.lcpp_stat().fetched_font_url_stat());
   };
   for (int i = 0; i < 2; ++i) {
-    LearnFontUrls(GURL("http://example.test"),
+    LearnFontUrls(GURL("https://example.test"),
                   {
                       GURL("https://example.test/test.woff"),
                       GURL("https://example.test/test.ttf"),
@@ -1748,7 +1752,7 @@
     EXPECT_DOUBLE_EQ(4, SumOfFontUrlFrequency(data));
   }
   for (int i = 0; i < 3; ++i) {
-    LearnFontUrls(GURL("http://example.test"),
+    LearnFontUrls(GURL("https://example.test"),
                   {
                       GURL("https://example.org/test.otf"),
                       GURL("https://example.net/test.svg"),
@@ -1776,7 +1780,7 @@
       network::mojom::RequestDestination::kImage;
   const int32_t kImageValue = static_cast<int32_t>(kImage);
   const std::string kUrl = "example.test";
-  const GURL kGURL = GURL("http://" + kUrl);
+  const GURL kGURL = GURL("https://" + kUrl);
   const std::string kJpegA = "https://" + kUrl + "/a.jpeg";
   const std::string kJpegB = "https://" + kUrl + "/b.jpeg";
 
@@ -1864,7 +1868,7 @@
   }
 
   // Confirm that new learning process reset the corrupted data.
-  LearnElementLocator(GURL("http://a.test"), "/#a", {});
+  LearnElementLocator(GURL("https://a.test"), "/#a", {});
   {
     LcppData data = CreateLcppData("a.test", 10);
     InitializeLcpElementLocatorBucket(data, "/#a", 1);
@@ -1879,21 +1883,21 @@
   InitializeDB(config);
   EXPECT_TRUE(GetDataMap().empty());
 
-  const GURL url_a("http://a.test");
+  const GURL url_a("https://a.test");
   EXPECT_FALSE(GetLcppStat(url_a));
 
   LearnElementLocator(url_a, "/#a");
   EXPECT_TRUE(GetLcppStat(url_a));
 
-  const GURL url_b("http://b.test");
+  const GURL url_b("https://b.test");
   LearnElementLocator(url_b, "/#a");
-  const GURL url_c("http://c.test");
+  const GURL url_c("https://c.test");
   LearnElementLocator(url_c, "/#a");
   EXPECT_TRUE(GetLcppStat(url_a));
   EXPECT_TRUE(GetLcppStat(url_b));
   EXPECT_TRUE(GetLcppStat(url_c));
 
-  const GURL url_d("http://d.test");
+  const GURL url_d("https://d.test");
   LearnElementLocator(url_d, "/#a");
   EXPECT_TRUE(GetLcppStat(url_d));
   // Confirm first host is dropped.
@@ -1914,11 +1918,11 @@
   InitializeDB(config);
 
   const std::vector<std::pair<std::string, std::string>> url_keys = {
-      {"http://a.test", "a.test"},
-      {"http://a.test/", "a.test"},
-      {"http://a.test/foo", "a.test/foo"},
-      {"http://a.test/bar?q=c", "a.test/bar?q=c"},
-      {"http://user:pass@a.test:99/foo;bar?q=a#ref", "a.test/foo;bar?q=a#ref"},
+      {"https://a.test", "a.test"},
+      {"https://a.test/", "a.test"},
+      {"https://a.test/foo", "a.test/foo"},
+      {"https://a.test/bar?q=c", "a.test/bar?q=c"},
+      {"https://user:pass@a.test:99/foo;bar?q=a#ref", "a.test/foo;bar?q=a#ref"},
   };
 
   TestLearnLcppURL(url_keys);
@@ -1930,9 +1934,9 @@
   config.max_hosts_to_track_for_lcpp = 10u;
   InitializeDB(config);
 
-  const GURL url_a("http://a.test");
-  const GURL url_b("http://b.test");
-  const GURL url_c("http://c.test");
+  const GURL url_a("https://a.test");
+  const GURL url_b("https://b.test");
+  const GURL url_c("https://c.test");
 
   LearnElementLocator(url_a, "/#a");
   LearnElementLocator(url_b, "/#a");
@@ -2005,33 +2009,33 @@
   const std::string too_long_path =
       "/" + std::string(max_path_length + 1, 'c') + "/bar";
   const std::vector<std::pair<std::string, std::string>> url_keys = {
-      {"http://a.test", "a.test"},
-      {"http://user:pass@a.test:99/foo;bar?q=a#ref", "a.test/foo;bar"},
-      {"http://a.test/", "a.test"},
-      {"http://a.test/foo.html", "a.test"},
-      {"http://a.test/foo", "a.test/foo"},
-      {"http://a.test/foo/", "a.test/foo"},
-      {"http://a.test/foo/bar", "a.test/foo"},
-      {"http://a.test/foo/bar/", "a.test/foo"},
-      {"http://a.test/foo/bar/baz.com", "a.test/foo"},
-      {"http://a.test/bar?q=c", "a.test/bar"},
-      {"http://a.test/foo/bar?q=c", "a.test/foo"},
-      {"http://a.test" + long_path, "a.test" + long_path},
-      {"http://a.test" + long_path + "/bar", "a.test" + long_path},
-      {"http://a.test" + long_path + "bar", "a.test"},
-      {"http://" + long_host + "/bar", long_host + "/bar"},
+      {"https://a.test", "a.test"},
+      {"https://user:pass@a.test:99/foo;bar?q=a#ref", "a.test/foo;bar"},
+      {"https://a.test/", "a.test"},
+      {"https://a.test/foo.html", "a.test"},
+      {"https://a.test/foo", "a.test/foo"},
+      {"https://a.test/foo/", "a.test/foo"},
+      {"https://a.test/foo/bar", "a.test/foo"},
+      {"https://a.test/foo/bar/", "a.test/foo"},
+      {"https://a.test/foo/bar/baz.com", "a.test/foo"},
+      {"https://a.test/bar?q=c", "a.test/bar"},
+      {"https://a.test/foo/bar?q=c", "a.test/foo"},
+      {"https://a.test" + long_path, "a.test" + long_path},
+      {"https://a.test" + long_path + "/bar", "a.test" + long_path},
+      {"https://a.test" + long_path + "bar", "a.test"},
+      {"https://" + long_host + "/bar", long_host + "/bar"},
       // Both valid but if the concated key is too long, take only host.
-      {"http://" + long_host + long_path, long_host},
+      {"https://" + long_host + long_path, long_host},
       // Too long path is ignored.
-      {"http://a.test" + too_long_path, "a.test"},
+      {"https://a.test" + too_long_path, "a.test"},
       // Invalid length path in subdirectory is also ignored.
-      {"http://a.test/bar" + too_long_path, "a.test/bar"}};
+      {"https://a.test/bar" + too_long_path, "a.test/bar"}};
 
   TestLearnLcppURL(url_keys);
 }
 
 TEST_P(LcppMultipleKeyTest, ShouldNotLearnTooLongLocators) {
-  const GURL url("http://a.test/foo1");
+  const GURL url("https://a.test/foo1");
   LearnElementLocator(url, "/#lcp");
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Persistent CSRF via LCPP database scheme confusion in subresource prefetching

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: The LCP Critical Path Predictor (LCPP) uses a host-only key for its database, ignoring URL schemes. This allows a network attacker to persistently poison the database via HTTP with an HTTPS subresource URL, which is later automatically prefetched with same-origin credentials during a subsequent HTTPS navigation.

Affected files:

  • chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util.cc
  • chrome/browser/page_load_metrics/observers/lcp_critical_path_predictor_page_load_metrics_observer.cc
  • chrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_host.cc
  • chrome/browser/predictors/prefetch_manager.cc
  • chrome/browser/predictors/perform_network_context_prefetch.cc
  • chrome/browser/predictors/loading_predictor_tab_helper.cc
  • chrome/browser/predictors/loading_predictor.cc
  • third_party/blink/renderer/core/lcp_critical_path_predictor/lcp_critical_path_predictor.cc

Estimated timestamp from git blame: 2025-10-01

Summary

The LCP Critical Path Predictor (LCPP) implementation in Chrome incorrectly keys its database records using only the host component of a URL, discarding the scheme. This architectural flaw creates a potential cross-scheme poisoning vulnerability. A network-level attacker (MITM) can inject malicious HTTPS subresource URLs into the LCPP database while a user browses the unencrypted HTTP version of a site. When the user later navigates to the secure HTTPS version of the same site, the browser automatically prefetches these attacker-controlled URLs.

Because the prefetch is triggered during the HTTPS navigation, the requests are sent as same-origin requests with SameSite=Strict cookies and Sec-Fetch-Site: same-origin headers. This effectively bypasses major CSRF defenses for state-changing GET endpoints.

Potential Attack Steps

(Note: These are suggested steps based on source code analysis; a working proof-of-concept has not been executed).

Preconditions:

  • The victim is on a network where an attacker can intercept HTTP traffic.
  • The feature blink::features::kLCPPPrefetchSubresource (and kLCPCriticalPathPredictor) is enabled (e.g., via Finch trial).
  • The target domain has state-changing GET requests protected by SameSite=Strict or Sec-Fetch-Site.

Phase 1: Poisoning via Insecure HTTP

  1. The user navigates to http://victim.com.
  2. The attacker intercepts the HTTP response and injects a subresource tag pointing to a secure endpoint, e.g., <script src="https://victim.com/api/sensitive-action"></script>.
  3. The renderer fetches the script and notifies the browser process via LCPCriticalPathPredictorHost::NotifyFetchedSubresource. This checks that the subresource is HTTP/HTTPS but does not ensure the scheme matches the document.
  4. LcpCriticalPathPredictorPageLoadMetricsObserver::AppendFetchedSubresourceUrl records the URL into lcpp_data_inputs_->subresource_urls without enforcing any IsSameSite validation against the document’s commit_url_ (http://victim.com).
  5. When the page load finalizes, LcppDataMap::LearnLcpp persists the data to the SQLite database. It generates the database key by calling GetLCPPDatabaseKey("http://victim.com"), which returns the scheme-less string "victim.com". The malicious HTTPS URL is now persistently tied to "victim.com".

Phase 2: Executing Authenticated CSRF via HTTPS Navigation 6. The user later navigates to the secure site: https://victim.com. 7. LoadingPredictorTabHelper::PrepareForPageLoad requests prefetch hints. LcppDataMap::GetPreconnectAndPrefetchRequest queries the database using GetLCPPDatabaseKey("https://victim.com"), which again evaluates to "victim.com", returning the poisoned entry. 8. The code validates the prediction using IsSameSite(https://victim.com, https://victim.com/api/sensitive-action). This passes because both URLs use the HTTPS scheme and share the same host. The cross-scheme mismatch from the write phase has been bypassed. 9. PerformNetworkContextPrefetch constructs the network request. It sets the request_initiator and site_for_cookies to the navigation origin: https://victim.com. 10. Because the target URL matches site_for_cookies, IsBannedCrossSiteAuth returns false, and the credentials_mode defaults to kInclude. 11. The Network Service dispatches the GET request. Seeing the matching site_for_cookies, it attaches SameSite=Strict and SameSite=Lax cookies. Seeing the matching request_initiator, it attaches Sec-Fetch-Site: same-origin. 12. The server receives the authenticated request and executes the state-changing action, completing the CSRF.

Suggested Fix

There are two primary ways to address this issue:

  1. Update Database Keying: GetLCPPDatabaseKey() in lcp_critical_path_predictor_util.cc should include the URL scheme (or at least differentiate between secure and insecure contexts), or
  2. Enforce Scheme/Site Matching on Write: Add an IsSameSite check against the commit_url_ within LcpCriticalPathPredictorPageLoadMetricsObserver::AppendFetchedSubresourceUrl, ensuring that an HTTP page cannot register HTTPS subresources for prefetching.

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