CVE-2026-78942
Overview
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:[email protected]: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::MaybePrewarmResourcesandLoadingPredictorTabHelperuse 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.
Attack Path
- 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. - LCPP learns from the HTTP page
Because
IsURLValidForLcppacceptedhttp, 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. - HTTPS navigation consults the poisoned entry
On a later HTTPS navigation to the same host,
LoadingPredictorTabHelperandLoadingPredictor::MaybePrewarmResourceslook up the same key and apply the attacker-supplied hints. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTchrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc |
modified | |
forchrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.cc |
modified | |
TEST_Pchrome/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.ccchrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_util_unittest.ccchrome/browser/predictors/loading_predictor.ccchrome/browser/predictors/loading_predictor_tab_helper.ccchrome/browser/predictors/loading_predictor_browsertest.cc
Audit Directions
- Scheme-less cache keysLook for other browser-side learned or cached state keyed on host/path only (other
chrome/browser/predictorstables, preconnect/prefetch predictors, site-engagement-style stores) whereSchemeIsHTTPOrHTTPS()admits HTTP writers for data later consumed by HTTPS loads. - Read/write gate symmetryVerify 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 predictorsWhere an
url::Originis converted withGetURL()and then validated (as in the initiator-origin path), check that scheme, opaque-origin and port handling match the URL-based path.
Patch
From c943aa2ef9f17f60658eaefcb736e89665d478dc Mon Sep 17 00:00:00 2001 From: Minoru Chikamune <[email protected]> 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 <[email protected]> Reviewed-by: Yoshisato Yanagisawa <[email protected]> Commit-Queue: Minoru Chikamune <[email protected]> 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:[email protected]: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:[email protected]: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:[email protected]: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:[email protected]: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:[email protected]: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:[email protected]: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)
Regression Test / PoC
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:[email protected]: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:[email protected]: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:[email protected]: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:[email protected]: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:[email protected]: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:[email protected]: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)
Original Bug Report
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.ccchrome/browser/page_load_metrics/observers/lcp_critical_path_predictor_page_load_metrics_observer.ccchrome/browser/predictors/lcp_critical_path_predictor/lcp_critical_path_predictor_host.ccchrome/browser/predictors/prefetch_manager.ccchrome/browser/predictors/perform_network_context_prefetch.ccchrome/browser/predictors/loading_predictor_tab_helper.ccchrome/browser/predictors/loading_predictor.ccthird_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(andkLCPCriticalPathPredictor) is enabled (e.g., via Finch trial). - The target domain has state-changing GET requests protected by
SameSite=StrictorSec-Fetch-Site.
Phase 1: Poisoning via Insecure HTTP
- The user navigates to
http://victim.com. - 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>. - 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. LcpCriticalPathPredictorPageLoadMetricsObserver::AppendFetchedSubresourceUrlrecords the URL intolcpp_data_inputs_->subresource_urlswithout enforcing anyIsSameSitevalidation against the document’scommit_url_(http://victim.com).- When the page load finalizes,
LcppDataMap::LearnLcpppersists the data to the SQLite database. It generates the database key by callingGetLCPPDatabaseKey("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:
- Update Database Keying:
GetLCPPDatabaseKey()inlcp_critical_path_predictor_util.ccshould include the URL scheme (or at least differentiate between secure and insecure contexts), or - Enforce Scheme/Site Matching on Write: Add an
IsSameSitecheck against thecommit_url_withinLcpCriticalPathPredictorPageLoadMetricsObserver::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.