CVE-2026-13966
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcomponents/history/core/browser/history_backend_unittest.cc |
modified |
Files Changed
components/history/core/browser/history_backend.cccomponents/history/core/browser/history_backend_unittest.cc
Patch
From 1c73bbb3e2c7d23e8421f3fcc1feebffb196e728 Mon Sep 17 00:00:00 2001
From: Marc Treib <treib@chromium.org>
Date: Mon, 01 Jun 2026 03:06:22 -0700
Subject: [PATCH] History: Don't consider IP literals for "typed" promotion
The history component automatically promotes intranet hosts (like
"http://myhost") to a "TYPED" transition type, so that the omnibox will
recognize the known host in the future.
Before this CL, IP literals were also detected as "intranet hosts" and
so the same promotion logic would (accidentally) apply to them.
This CL fixes this by excluding IP literals from
IsUntypedIntranetHost().
Fixed: 513741393
Change-Id: Ia52da2625c3da177fc35e119e3e5b54c6a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7864242
Commit-Queue: Marc Treib <treib@chromium.org>
Reviewed-by: manuk hovanesian <manukh@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1639258}
---
diff --git a/components/history/core/browser/history_backend.cc b/components/history/core/browser/history_backend.cc
index 2e7830e..0098d98 100644
--- a/components/history/core/browser/history_backend.cc
+++ b/components/history/core/browser/history_backend.cc
@@ -64,6 +64,7 @@
#include "components/sync/model/client_tag_based_data_type_processor.h"
#include "components/url_formatter/url_formatter.h"
#include "net/base/registry_controlled_domains/registry_controlled_domain.h"
+#include "net/base/url_util.h"
#include "sql/error_delegate_util.h"
#include "sql/sqlite_result_code.h"
#include "sql/sqlite_result_code_values.h"
@@ -929,6 +930,15 @@
return false;
}
+ // A host is usually considered "intranet" if it has no eTLD suffix (no ".com"
+ // or ".co.uk" etc), i.e. its "registry length" is zero - see
+ // net/base/registry_controlled_domains/registry_controlled_domain.h for more
+ // details. However, IP addresses also don't have an eTLD, but do not
+ // generally belong to an intranet, so they must be separately excluded here.
+ if (url.HostIsIPAddress() && !net::IsLocalhost(url)) {
+ return false;
+ }
+
const std::string host = url.GetHost();
const size_t registry_length =
net::registry_controlled_domains::GetCanonicalHostRegistryLength(
diff --git a/components/history/core/browser/history_backend_unittest.cc b/components/history/core/browser/history_backend_unittest.cc
index dca3d2f4..45556089 100644
--- a/components/history/core/browser/history_backend_unittest.cc
+++ b/components/history/core/browser/history_backend_unittest.cc
@@ -1898,6 +1898,86 @@
EXPECT_EQ(visits[2].consider_for_ntp_most_visited, true);
}
+TEST_F(HistoryBackendTest, UntypedIntranetHostPromotion) {
+ ASSERT_TRUE(backend_.get());
+
+ // 1. An untyped intranet host should be promoted to TYPED.
+ {
+ GURL intranet_url("http://myhost");
+ HistoryAddPageArgs request(intranet_url, base::Time::Now(), 0, 0,
+ std::nullopt, GURL(), RedirectList(),
+ ui::PAGE_TRANSITION_LINK, false, SOURCE_BROWSED,
+ VisitResponseCodeCategory::kNot404, false, true);
+ backend_->AddPage(request);
+
+ VisitVector visits;
+ URLRow row;
+ URLID id = backend_->db()->GetRowForURL(intranet_url, &row);
+ ASSERT_TRUE(backend_->db()->GetVisitsForURL(id, &visits));
+ ASSERT_EQ(1U, visits.size());
+ // Should be promoted to TYPED.
+ EXPECT_TRUE(ui::PageTransitionCoreTypeIs(visits[0].transition,
+ ui::PAGE_TRANSITION_TYPED));
+ }
+
+ // 2. A public IPv4 literal should NOT be promoted to TYPED.
+ {
+ GURL ipv4_url("https://8.8.8.8");
+ HistoryAddPageArgs request(ipv4_url, base::Time::Now(), 0, 0, std::nullopt,
+ GURL(), RedirectList(), ui::PAGE_TRANSITION_LINK,
+ false, SOURCE_BROWSED,
+ VisitResponseCodeCategory::kNot404, false, true);
+ backend_->AddPage(request);
+
+ VisitVector visits;
+ URLRow row;
+ URLID id = backend_->db()->GetRowForURL(ipv4_url, &row);
+ ASSERT_TRUE(backend_->db()->GetVisitsForURL(id, &visits));
+ ASSERT_EQ(1U, visits.size());
+ // Should NOT be promoted to TYPED (should remain LINK).
+ EXPECT_TRUE(ui::PageTransitionCoreTypeIs(visits[0].transition,
+ ui::PAGE_TRANSITION_LINK));
+ }
+
+ // 3. A public IPv6 literal should NOT be promoted to TYPED.
+ {
+ GURL ipv6_url("https://[2001:4860:4860::8888]");
+ HistoryAddPageArgs request(ipv6_url, base::Time::Now(), 0, 0, std::nullopt,
+ GURL(), RedirectList(), ui::PAGE_TRANSITION_LINK,
+ false, SOURCE_BROWSED,
+ VisitResponseCodeCategory::kNot404, false, true);
+ backend_->AddPage(request);
+
+ VisitVector visits;
+ URLRow row;
+ URLID id = backend_->db()->GetRowForURL(ipv6_url, &row);
+ ASSERT_TRUE(backend_->db()->GetVisitsForURL(id, &visits));
+ ASSERT_EQ(1U, visits.size());
+ // Should NOT be promoted to TYPED (should remain LINK).
+ EXPECT_TRUE(ui::PageTransitionCoreTypeIs(visits[0].transition,
+ ui::PAGE_TRANSITION_LINK));
+ }
+
+ // 4. A regular public domain should NOT be promoted to TYPED.
+ {
+ GURL public_url("https://google.com");
+ HistoryAddPageArgs request(public_url, base::Time::Now(), 0, 0,
+ std::nullopt, GURL(), RedirectList(),
+ ui::PAGE_TRANSITION_LINK, false, SOURCE_BROWSED,
+ VisitResponseCodeCategory::kNot404, false, true);
+ backend_->AddPage(request);
+
+ VisitVector visits;
+ URLRow row;
+ URLID id = backend_->db()->GetRowForURL(public_url, &row);
+ ASSERT_TRUE(backend_->db()->GetVisitsForURL(id, &visits));
+ ASSERT_EQ(1U, visits.size());
+ // Should NOT be promoted to TYPED (should remain LINK).
+ EXPECT_TRUE(ui::PageTransitionCoreTypeIs(visits[0].transition,
+ ui::PAGE_TRANSITION_LINK));
+ }
+}
+
TEST_F(HistoryBackendTest, AddContentModelAnnotationsWithNoEntryInVisitTable) {
ASSERT_TRUE(backend_.get());
Regression Test / PoC
diff --git a/components/history/core/browser/history_backend_unittest.cc b/components/history/core/browser/history_backend_unittest.cc
index dca3d2f4..45556089 100644
--- a/components/history/core/browser/history_backend_unittest.cc
+++ b/components/history/core/browser/history_backend_unittest.cc
@@ -1898,6 +1898,86 @@
EXPECT_EQ(visits[2].consider_for_ntp_most_visited, true);
}
+TEST_F(HistoryBackendTest, UntypedIntranetHostPromotion) {
+ ASSERT_TRUE(backend_.get());
+
+ // 1. An untyped intranet host should be promoted to TYPED.
+ {
+ GURL intranet_url("http://myhost");
+ HistoryAddPageArgs request(intranet_url, base::Time::Now(), 0, 0,
+ std::nullopt, GURL(), RedirectList(),
+ ui::PAGE_TRANSITION_LINK, false, SOURCE_BROWSED,
+ VisitResponseCodeCategory::kNot404, false, true);
+ backend_->AddPage(request);
+
+ VisitVector visits;
+ URLRow row;
+ URLID id = backend_->db()->GetRowForURL(intranet_url, &row);
+ ASSERT_TRUE(backend_->db()->GetVisitsForURL(id, &visits));
+ ASSERT_EQ(1U, visits.size());
+ // Should be promoted to TYPED.
+ EXPECT_TRUE(ui::PageTransitionCoreTypeIs(visits[0].transition,
+ ui::PAGE_TRANSITION_TYPED));
+ }
+
+ // 2. A public IPv4 literal should NOT be promoted to TYPED.
+ {
+ GURL ipv4_url("https://8.8.8.8");
+ HistoryAddPageArgs request(ipv4_url, base::Time::Now(), 0, 0, std::nullopt,
+ GURL(), RedirectList(), ui::PAGE_TRANSITION_LINK,
+ false, SOURCE_BROWSED,
+ VisitResponseCodeCategory::kNot404, false, true);
+ backend_->AddPage(request);
+
+ VisitVector visits;
+ URLRow row;
+ URLID id = backend_->db()->GetRowForURL(ipv4_url, &row);
+ ASSERT_TRUE(backend_->db()->GetVisitsForURL(id, &visits));
+ ASSERT_EQ(1U, visits.size());
+ // Should NOT be promoted to TYPED (should remain LINK).
+ EXPECT_TRUE(ui::PageTransitionCoreTypeIs(visits[0].transition,
+ ui::PAGE_TRANSITION_LINK));
+ }
+
+ // 3. A public IPv6 literal should NOT be promoted to TYPED.
+ {
+ GURL ipv6_url("https://[2001:4860:4860::8888]");
+ HistoryAddPageArgs request(ipv6_url, base::Time::Now(), 0, 0, std::nullopt,
+ GURL(), RedirectList(), ui::PAGE_TRANSITION_LINK,
+ false, SOURCE_BROWSED,
+ VisitResponseCodeCategory::kNot404, false, true);
+ backend_->AddPage(request);
+
+ VisitVector visits;
+ URLRow row;
+ URLID id = backend_->db()->GetRowForURL(ipv6_url, &row);
+ ASSERT_TRUE(backend_->db()->GetVisitsForURL(id, &visits));
+ ASSERT_EQ(1U, visits.size());
+ // Should NOT be promoted to TYPED (should remain LINK).
+ EXPECT_TRUE(ui::PageTransitionCoreTypeIs(visits[0].transition,
+ ui::PAGE_TRANSITION_LINK));
+ }
+
+ // 4. A regular public domain should NOT be promoted to TYPED.
+ {
+ GURL public_url("https://google.com");
+ HistoryAddPageArgs request(public_url, base::Time::Now(), 0, 0,
+ std::nullopt, GURL(), RedirectList(),
+ ui::PAGE_TRANSITION_LINK, false, SOURCE_BROWSED,
+ VisitResponseCodeCategory::kNot404, false, true);
+ backend_->AddPage(request);
+
+ VisitVector visits;
+ URLRow row;
+ URLID id = backend_->db()->GetRowForURL(public_url, &row);
+ ASSERT_TRUE(backend_->db()->GetVisitsForURL(id, &visits));
+ ASSERT_EQ(1U, visits.size());
+ // Should NOT be promoted to TYPED (should remain LINK).
+ EXPECT_TRUE(ui::PageTransitionCoreTypeIs(visits[0].transition,
+ ui::PAGE_TRANSITION_LINK));
+ }
+}
+
TEST_F(HistoryBackendTest, AddContentModelAnnotationsWithNoEntryInVisitTable) {
ASSERT_TRUE(backend_.get());
Original Bug Report
History metadata poisoning via IP-literal transition promotion
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic flaw in HistoryBackend incorrectly classifies public IP literals as intranet hosts, leading to the promotion of navigations to a ’typed’ transition. Attackers can exploit this via a redirect chain to artificially inflate the typed_count of malicious domains. This poisons Omnibox autocomplete rankings and New Tab Page tiles, potentially facilitating phishing attacks.
Affected files:
components/history/core/browser/history_backend.ccchrome/browser/history/history_tab_helper.cccomponents/history/core/browser/url_database.cc
Estimated timestamp from git blame: 2017-03-20
Description
A logic flaw in HistoryBackend::IsUntypedIntranetHost incorrectly identifies public IPv4 and IPv6 literals as “untyped intranet hosts.” This misclassification causes the History system to promote certain navigations to the ui::PAGE_TRANSITION_TYPED transition, even when the user did not manually type the URL.
An attacker can exploit this behavior by inducing a navigation to a malicious domain that redirects to a public IP literal. Because the promotion logic checks both the start and the end of redirect chains, this sequence causes the typed_count of the attacker’s domain (the start of the chain) to be incremented. This metadata poisoning allows malicious domains to achieve high rankings in Omnibox autocomplete suggestions and appear in the New Tab Page (NTP) Most-Visited tiles.
Root Cause Analysis
The issue resides in components/history/core/browser/history_backend.cc within the IsUntypedIntranetHost function:
bool HistoryBackend::IsUntypedIntranetHost(const GURL& url) {
// ... scheme checks ...
const std::string host = url.GetHost();
const size_t registry_length =
net::registry_controlled_domains::GetCanonicalHostRegistryLength(
host, net::registry_controlled_domains::EXCLUDE_UNKNOWN_REGISTRIES,
net::registry_controlled_domains::EXCLUDE_PRIVATE_REGISTRIES);
return (registry_length == 0) && !db_->IsTypedHost(host, /*scheme=*/nullptr);
}
For IP literals, GetCanonicalHostRegistryLength returns 0. If the IP has not been manually typed before, IsTypedHost returns false, and the function classifies the IP as an intranet host.
In HistoryBackend::AddPage, if either the initial URL or the final URL of a redirect chain is considered an untyped intranet host, the navigation transition is rewritten to TYPED (lines 995-1001).
When processing the redirect chain:
- The first entry (attacker’s domain) receives the transition
TYPED | CHAIN_START. Because it lacks redirect qualifiers (likeSERVER_REDIRECT),HistoryBackend::IsTypedIncrementreturns true, and itstyped_countis incremented. - The final entry (the IP literal) receives
TYPED | SERVER_REDIRECT.IsTypedIncrementreturns false due to the redirect qualifier, ensuring the IP’styped_countremains zero. This allows the attacker to repeat the process indefinitely to inflate thetyped_countof the phishing domain.
Potential Impact
- Omnibox Ranking Manipulation:
typed_countis a primary ranking factor for Omnibox suggestions. Attackers can make phishing domains appear as high-confidence results. - New Tab Page (NTP) Poisoning: Inflated segment usage data can cause malicious sites to appear in the user’s Most-Visited tiles.
Potential Reproduction Steps
- Host a page at
https://attacker.example/rthat returns a 302 redirect to a public IP literal (e.g.,http://203.0.113.7/). - From a separate page, induce a user gesture that triggers a navigation to
https://attacker.example/r(e.g., viawindow.open). - Repeat this navigation multiple times (this can be automated via script after the initial gesture).
- Observe the
urlstable in the History database. Thetyped_countforattacker.examplewill increment on each visit. - Verify that
attacker.examplenow appears as a top suggestion in the Omnibox and in NTP tiles.
Suggested Fix
Modify HistoryBackend::IsUntypedIntranetHost to explicitly exclude IP literals. This can be achieved by checking if the host is an IP address before proceeding with the registry length check:
bool HistoryBackend::IsUntypedIntranetHost(const GURL& url) {
if (!url.SchemeIsHTTPOrHTTPS() && !url.SchemeIs(url::kFtpScheme))
return false;
if (url.HostIsIPAddress())
return false;
// ... existing registry_length logic ...
}
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.