CVE-2026-113073
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/history_embeddings/history_embeddings_tab_helper.cc |
modified | |
forchrome/browser/history_embeddings/history_embeddings_service_browsertest.cc |
modified | |
ifchrome/browser/history_embeddings/history_embeddings_service_browsertest.cc |
modified |
Files Changed
chrome/browser/history_embeddings/history_embeddings_service_browsertest.ccchrome/browser/history_embeddings/history_embeddings_tab_helper.ccchrome/browser/history_embeddings/history_embeddings_tab_helper.hcomponents/history_embeddings/content/history_embeddings_service.cc
Patch
From 04e44ca533dcf1874b40fede1cf8ea165b6900fe Mon Sep 17 00:00:00 2001 From: Orin Jaworski <[email protected]> Date: Tue, 15 Sep 2026 22:42:00 -0700 Subject: [PATCH] Reset cached visit state across navigations in history embeddings When a navigation does not record history visits (such as on HTTP 404 or network errors) or interrupts an in-flight page load, HistoryEmbeddingsTabHelper could retain stale cached visit metadata and pending database queries. This could cause passages extracted from a subsequent document to be associated with the previous navigation's visit metadata. This CL tracks the navigation ID to uniquely correlate cached visit state with its corresponding navigation lifecycle. In addition, in-flight history lookups are cancelled and cached visit metadata is reset on new navigations and early returns. Fixed: 519217664 Change-Id: Ib95c23437117543ad2cd9897617ae111992d2386 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8403770 Reviewed-by: John Lee <[email protected]> Auto-Submit: Orin Jaworski <[email protected]> Commit-Queue: John Lee <[email protected]> Cr-Commit-Position: refs/heads/main@{#1698656} --- diff --git a/chrome/browser/history_embeddings/history_embeddings_tab_helper.cc b/chrome/browser/history_embeddings/history_embeddings_tab_helper.cc index 276c42b..b71ae41 100644 --- a/chrome/browser/history_embeddings/history_embeddings_tab_helper.cc +++ b/chrome/browser/history_embeddings/history_embeddings_tab_helper.cc @@ -48,31 +48,61 @@ CancelHistoryLookup(); // Save data for later use in `DidFinishLoad`. + history_navigation_id_ = navigation_id; history_visit_time_ = timestamp; history_url_ = url; - GetHistoryEmbeddingsService()->UpdateVisitMetadata(web_contents(), - std::nullopt); + if (history_embeddings::HistoryEmbeddingsService* service = + GetHistoryEmbeddingsService()) { + service->UpdateVisitMetadata(web_contents(), std::nullopt); + } } void HistoryEmbeddingsTabHelper::DidFinishNavigation( content::NavigationHandle* navigation_handle) { - if (navigation_handle->IsInPrimaryMainFrame() && - navigation_handle->HasCommitted() && - !navigation_handle->IsSameDocument()) { - GetHistoryEmbeddingsService()->UpdateVisitMetadata(web_contents(), - std::nullopt); + if (!navigation_handle->IsInPrimaryMainFrame() || + !navigation_handle->HasCommitted()) { + return; + } + + if (navigation_handle->IsSameDocument() || + navigation_handle->IsServedFromBackForwardCache()) { + // Same-document and BFCache navigations do not trigger a fresh + // DidFinishLoad, so clear any pending navigation state so it does not + // linger. + CancelHistoryLookup(); + history_navigation_id_.reset(); + history_visit_time_.reset(); + history_url_.reset(); + if (history_embeddings::HistoryEmbeddingsService* service = + GetHistoryEmbeddingsService()) { + service->UpdateVisitMetadata(web_contents(), std::nullopt); + } + return; + } + + // For cross-document navigations: + // If this navigation did not update history (e.g. 404 response, network + // error, or ineligible navigation), clear any stale history data and cancel + // pending lookups. + if (!history_navigation_id_.has_value() || + *history_navigation_id_ != navigation_handle->GetNavigationId()) { + CancelHistoryLookup(); + history_navigation_id_.reset(); + history_visit_time_.reset(); + history_url_.reset(); + } + + if (history_embeddings::HistoryEmbeddingsService* service = + GetHistoryEmbeddingsService()) { + service->UpdateVisitMetadata(web_contents(), std::nullopt); } } void HistoryEmbeddingsTabHelper::DidFinishLoad( content::RenderFrameHost* render_frame_host, const GURL& validated_url) { - if (!render_frame_host->IsInPrimaryMainFrame() || - !history_embeddings::IsHistoryEmbeddingsEnabledForProfile( - Profile::FromBrowserContext(web_contents()->GetBrowserContext())) || - !GetHistoryEmbeddingsService() || - !GetHistoryEmbeddingsService()->IsEligible(validated_url)) { + if (!render_frame_host->IsInPrimaryMainFrame()) { return; } @@ -82,25 +112,72 @@ // guarantees at most one delayed task is scheduled at a time. CancelHistoryLookup(); - if (!history_url_.has_value()) { + if (!history_embeddings::IsHistoryEmbeddingsEnabledForProfile( + Profile::FromBrowserContext(web_contents()->GetBrowserContext())) || + !GetHistoryEmbeddingsService() || + !GetHistoryEmbeddingsService()->IsEligible(validated_url) || + !history_url_.has_value() || *history_url_ != validated_url) { + history_navigation_id_.reset(); + history_visit_time_.reset(); + history_url_.reset(); return; } - if (history::HistoryService* history_service = GetHistoryService()) { - // Callback is a member method instead of inline to enable cancellation via - // weak pointer in `CancelHistoryLookup()`. - history_service->GetMostRecentVisitsForGurl( - history_url_.value(), 1, history::VisitQuery404sPolicy::kExclude404s, - base::BindOnce( - &HistoryEmbeddingsTabHelper::UpdateEmbeddingsServiceWithHistoryData, - extraction_weak_ptr_factory_.GetWeakPtr()), - &task_tracker_); + history::HistoryService* history_service = GetHistoryService(); + if (!history_service) { + history_navigation_id_.reset(); + history_visit_time_.reset(); + history_url_.reset(); + return; + } + + // Callback is a member method instead of inline to enable cancellation via + // weak pointer in `CancelHistoryLookup()`. + history_service->GetMostRecentVisitsForGurl( + history_url_.value(), 1, history::VisitQuery404sPolicy::kExclude404s, + base::BindOnce( + &HistoryEmbeddingsTabHelper::UpdateEmbeddingsServiceWithHistoryData, + extraction_weak_ptr_factory_.GetWeakPtr()), + &task_tracker_); +} + +void HistoryEmbeddingsTabHelper::DidFailLoad( + content::RenderFrameHost* render_frame_host, + const GURL& validated_url, + int error_code) { + if (render_frame_host->IsInPrimaryMainFrame()) { + CancelHistoryLookup(); + history_navigation_id_.reset(); + history_visit_time_.reset(); + history_url_.reset(); + if (history_embeddings::HistoryEmbeddingsService* service = + GetHistoryEmbeddingsService()) { + service->UpdateVisitMetadata(web_contents(), std::nullopt); + } + } +} + +void HistoryEmbeddingsTabHelper::PrimaryMainFrameRenderProcessGone( + base::TerminationStatus status) { + CancelHistoryLookup(); + history_navigation_id_.reset(); + history_visit_time_.reset(); + history_url_.reset(); + if (history_embeddings::HistoryEmbeddingsService* service = + GetHistoryEmbeddingsService()) { + service->UpdateVisitMetadata(web_contents(), std::nullopt); } } void HistoryEmbeddingsTabHelper::WebContentsDestroyed() { - GetHistoryEmbeddingsService()->UpdateVisitMetadata(web_contents(), - std::nullopt); + CancelHistoryLookup(); + history_navigation_id_.reset(); + history_visit_time_.reset(); + history_url_.reset(); + if (history_embeddings::HistoryEmbeddingsService* service = + GetHistoryEmbeddingsService()) { + service->UpdateVisitMetadata(web_contents(), std::nullopt); + } } void HistoryEmbeddingsTabHelper::SetHistoryTabHelperSubscription( @@ -115,6 +192,14 @@ void HistoryEmbeddingsTabHelper::UpdateEmbeddingsServiceWithHistoryData( history::QueryURLAndVisitsResult result) { + std::optional<base::Time> expected_visit_time = history_visit_time_; + std::optional<GURL> expected_url = history_url_; + + // Clear the data. It isn't reused and will be set anew by later navigation. + history_navigation_id_.reset(); + history_visit_time_.reset(); + history_url_.reset(); + // `visits` can be empty for navigations that don't result in a // visit being added to the DB, e.g. navigations to // "chrome://" URLs. @@ -128,8 +213,16 @@ CHECK_EQ(url_row.id(), latest_visit.url_id); // Make sure the visit we got actually corresponds to the // navigation by comparing the visit_times. - if (!history_visit_time_.has_value() || - latest_visit.visit_time != *history_visit_time_) { + if (!expected_visit_time.has_value() || + latest_visit.visit_time != *expected_visit_time) { + return; + } + // Make sure the URL from the history query matches the expected URL and + // the currently committed URL of the WebContents. + if (!expected_url.has_value() || url_row.url() != *expected_url) { + return; + } + if (web_contents()->GetLastCommittedURL() != url_row.url()) { return; } // Make sure the latest visit (the first one in the array) is @@ -143,14 +236,14 @@ return; } - GetHistoryEmbeddingsService()->UpdateVisitMetadata( - web_contents(), - history_embeddings::HistoryEmbeddingsService::VisitMetadata{ - latest_visit.url_id, latest_visit.visit_id, latest_visit.visit_time}); - - // Clear the data. It isn't reused and will be set anew by later navigation. - history_visit_time_.reset(); - history_url_.reset(); + if (history_embeddings::HistoryEmbeddingsService* service = + GetHistoryEmbeddingsService()) { + service->UpdateVisitMetadata( + web_contents(), + history_embeddings::HistoryEmbeddingsService::VisitMetadata{ + latest_visit.url_id, latest_visit.visit_id, + latest_visit.visit_time}); + } } void HistoryEmbeddingsTabHelper::CancelHistoryLookup() { diff --git a/chrome/browser/history_embeddings/history_embeddings_tab_helper.h b/chrome/browser/history_embeddings/history_embeddings_tab_helper.h index 056f39e..0478a702 100644 --- a/chrome/browser/history_embeddings/history_embeddings_tab_helper.h +++ b/chrome/browser/history_embeddings/history_embeddings_tab_helper.h @@ -50,8 +50,13 @@ // content::WebContentsObserver: void DidFinishLoad(content::RenderFrameHost* render_frame_host, const GURL& validated_url) override; + void DidFailLoad(content::RenderFrameHost* render_frame_host, + const GURL& validated_url, + int error_code) override; void DidFinishNavigation( content::NavigationHandle* navigation_handle) override; + void PrimaryMainFrameRenderProcessGone( + base::TerminationStatus status) override; void WebContentsDestroyed() override; void SetHistoryTabHelperSubscription( @@ -84,6 +89,7 @@ // Data saved from the `HistoryTabHelper` call to // `OnUpdatedHistoryForNavigation` which happens in `DidFinishNavigation` // and precedes `DidFinishLoad`. + std::optional<int64_t> history_navigation_id_; std::optional<base::Time> history_visit_time_; std::optional<GURL> history_url_; diff --git a/components/history_embeddings/content/history_embeddings_service.cc b/components/history_embeddings/content/history_embeddings_service.cc index f42c3c0..e0fcc5d5 100644 --- a/components/history_embeddings/content/history_embeddings_service.cc +++ b/components/history_embeddings/content/history_embeddings_service.cc @@ -488,6 +488,9 @@ } void HistoryEmbeddingsService::OnPageEmbeddingsAvailable(content::Page& page) { + if (!page.IsPrimary()) { + return; + } auto* const web_contents = content::WebContents::FromRenderFrameHost(&page.GetMainDocument()); const auto loc = last_history_visit_.find(web_contents); diff --git a/chrome/browser/history_embeddings/history_embeddings_service_browsertest.cc b/chrome/browser/history_embeddings/history_embeddings_service_browsertest.cc index ffe4e59..6c89829f 100644 --- a/chrome/browser/history_embeddings/history_embeddings_service_browsertest.cc +++ b/chrome/browser/history_embeddings/history_embeddings_service_browsertest.cc @@ -42,6 +42,10 @@ #include "components/passage_embeddings/core/passage_embeddings_test_util.h" #include "content/public/browser/weak_document_ptr.h" #include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "net/test/embedded_test_server/controllable_http_response.h" +#include "net/test/embedded_test_server/http_request.h" +#include "net/test/embedded_test_server/http_response.h" #if BUILDFLAG(IS_CHROMEOS) #include "chromeos/constants/chromeos_features.h" @@ -652,52 +656,190 @@ } IN_PROC_BROWSER_TEST_F(HistoryEmbeddingsBrowserTest, - 404NavigationDoesNotPoisonHistory) { + NotFoundNavigationDoesNotAssociateWithPreviousUrl) { OverrideVisibilityScoresForTesting({ - {"Victim passages", 0.99}, - {"Attacker passages", 0.99}, + {"First sample passages", 0.99}, + {"Second sample passages", 0.99}, }); ASSERT_TRUE(embedded_test_server()->Start()); - // 1. Load victim page. - SetPagePassages({"Victim passages"}); + // 1. Load first page to completion. + SetPagePassages({"First sample passages"}); base::test::TestFuture<UrlData> store_future; service()->SetPassagesStoredCallbackForTesting( store_future.GetRepeatingCallback()); - GURL victim_url = embedded_test_server()->GetURL("/links.html"); - ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), victim_url)); + GURL first_url = embedded_test_server()->GetURL("/links.html"); + EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), first_url)); EXPECT_TRUE(store_future.Wait()); - // 2. Navigate to 404 page with attacker passages. - SetPagePassages({"Attacker passages"}); + // 2. Navigate to 404 page with second passages. + SetPagePassages({"Second sample passages"}); base::test::TestFuture<void> extraction_triggered; SetExtractionCallback(extraction_triggered.GetRepeatingCallback()); - GURL poison_url = embedded_test_server()->GetURL("/non-existent-404"); - ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), poison_url)); + GURL not_found_url = embedded_test_server()->GetURL("/non-existent-404"); + EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), not_found_url)); // Wait specifically for the content extraction to be triggered for the 404 - // page + // page. EXPECT_TRUE(extraction_triggered.Wait()); - // Now check if the victim URL has been poisoned. + // Check that the first URL has not been associated with the second page's + // passages. base::test::TestFuture<SearchResult> search_future; - service()->Search(nullptr, "Attacker", {}, 1, /*skip_answering=*/false, + service()->Search(nullptr, "Second", {}, 1, /*skip_answering=*/false, /*url_id_filter=*/{}, search_future.GetRepeatingCallback()); SearchResult result = search_future.Take(); - bool found_victim_with_attacker_content = false; + bool found_first_with_second_content = false; for (const auto& scored_url_row : result.scored_url_rows) { - if (scored_url_row.row.url() == victim_url) { - found_victim_with_attacker_content = true; + if (scored_url_row.row.url() == first_url) { + found_first_with_second_content = true; break; } } - EXPECT_FALSE(found_victim_with_attacker_content) - << "Victim URL was poisoned with attacker content!"; + EXPECT_FALSE(found_first_with_second_content); +} + +IN_PROC_BROWSER_TEST_F( + HistoryEmbeddingsBrowserTest, + NotFoundNavigationInterruptingInitialLoadDoesNotRetainPriorPageMetadata) { + OverrideVisibilityScoresForTesting({ + {"Sample initial page content", 0.99}, + {"Sample second page content", 0.99}, + }); + + net::test_server::ControllableHttpResponse slow_response( + embedded_test_server(), "/slow_resource"); + + embedded_test_server()->RegisterRequestHandler(base::BindRepeating( + [](const net::test_server::HttpRequest& request) + -> std::unique_ptr<net::test_server::HttpResponse> { + if (request.relative_url == "/page_with_slow_image") { + auto response = + std::make_unique<net::test_server::BasicHttpResponse>(); + response->set_code(net::HTTP_OK); + response->set_content_type("text/html"); + response->set_content( + "<html><body><h1>First Page</h1><img " + "src=\"/slow_resource\"></body></html>"); + return response; + } + return nullptr; + })); + + ASSERT_TRUE(embedded_test_server()->Start()); + ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/history_embeddings/history_embeddings_service_browsertest.cc b/chrome/browser/history_embeddings/history_embeddings_service_browsertest.cc
index ffe4e59..6c89829f 100644
--- a/chrome/browser/history_embeddings/history_embeddings_service_browsertest.cc
+++ b/chrome/browser/history_embeddings/history_embeddings_service_browsertest.cc
@@ -42,6 +42,10 @@
#include "components/passage_embeddings/core/passage_embeddings_test_util.h"
#include "content/public/browser/weak_document_ptr.h"
#include "content/public/test/browser_test.h"
+#include "content/public/test/browser_test_utils.h"
+#include "net/test/embedded_test_server/controllable_http_response.h"
+#include "net/test/embedded_test_server/http_request.h"
+#include "net/test/embedded_test_server/http_response.h"
#if BUILDFLAG(IS_CHROMEOS)
#include "chromeos/constants/chromeos_features.h"
@@ -652,52 +656,190 @@
}
IN_PROC_BROWSER_TEST_F(HistoryEmbeddingsBrowserTest,
- 404NavigationDoesNotPoisonHistory) {
+ NotFoundNavigationDoesNotAssociateWithPreviousUrl) {
OverrideVisibilityScoresForTesting({
- {"Victim passages", 0.99},
- {"Attacker passages", 0.99},
+ {"First sample passages", 0.99},
+ {"Second sample passages", 0.99},
});
ASSERT_TRUE(embedded_test_server()->Start());
- // 1. Load victim page.
- SetPagePassages({"Victim passages"});
+ // 1. Load first page to completion.
+ SetPagePassages({"First sample passages"});
base::test::TestFuture<UrlData> store_future;
service()->SetPassagesStoredCallbackForTesting(
store_future.GetRepeatingCallback());
- GURL victim_url = embedded_test_server()->GetURL("/links.html");
- ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), victim_url));
+ GURL first_url = embedded_test_server()->GetURL("/links.html");
+ EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), first_url));
EXPECT_TRUE(store_future.Wait());
- // 2. Navigate to 404 page with attacker passages.
- SetPagePassages({"Attacker passages"});
+ // 2. Navigate to 404 page with second passages.
+ SetPagePassages({"Second sample passages"});
base::test::TestFuture<void> extraction_triggered;
SetExtractionCallback(extraction_triggered.GetRepeatingCallback());
- GURL poison_url = embedded_test_server()->GetURL("/non-existent-404");
- ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), poison_url));
+ GURL not_found_url = embedded_test_server()->GetURL("/non-existent-404");
+ EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), not_found_url));
// Wait specifically for the content extraction to be triggered for the 404
- // page
+ // page.
EXPECT_TRUE(extraction_triggered.Wait());
- // Now check if the victim URL has been poisoned.
+ // Check that the first URL has not been associated with the second page's
+ // passages.
base::test::TestFuture<SearchResult> search_future;
- service()->Search(nullptr, "Attacker", {}, 1, /*skip_answering=*/false,
+ service()->Search(nullptr, "Second", {}, 1, /*skip_answering=*/false,
/*url_id_filter=*/{}, search_future.GetRepeatingCallback());
SearchResult result = search_future.Take();
- bool found_victim_with_attacker_content = false;
+ bool found_first_with_second_content = false;
for (const auto& scored_url_row : result.scored_url_rows) {
- if (scored_url_row.row.url() == victim_url) {
- found_victim_with_attacker_content = true;
+ if (scored_url_row.row.url() == first_url) {
+ found_first_with_second_content = true;
break;
}
}
- EXPECT_FALSE(found_victim_with_attacker_content)
- << "Victim URL was poisoned with attacker content!";
+ EXPECT_FALSE(found_first_with_second_content);
+}
+
+IN_PROC_BROWSER_TEST_F(
+ HistoryEmbeddingsBrowserTest,
+ NotFoundNavigationInterruptingInitialLoadDoesNotRetainPriorPageMetadata) {
+ OverrideVisibilityScoresForTesting({
+ {"Sample initial page content", 0.99},
+ {"Sample second page content", 0.99},
+ });
+
+ net::test_server::ControllableHttpResponse slow_response(
+ embedded_test_server(), "/slow_resource");
+
+ embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
+ [](const net::test_server::HttpRequest& request)
+ -> std::unique_ptr<net::test_server::HttpResponse> {
+ if (request.relative_url == "/page_with_slow_image") {
+ auto response =
+ std::make_unique<net::test_server::BasicHttpResponse>();
+ response->set_code(net::HTTP_OK);
+ response->set_content_type("text/html");
+ response->set_content(
+ "<html><body><h1>First Page</h1><img "
+ "src=\"/slow_resource\"></body></html>");
+ return response;
+ }
+ return nullptr;
+ }));
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ // 1. Start navigation to the first page with a slow subresource.
+ SetPagePassages({"Sample initial page content"});
+ GURL first_url = embedded_test_server()->GetURL("/page_with_slow_image");
+ browser()->OpenURL(content::OpenURLParams(first_url, content::Referrer(),
+ WindowOpenDisposition::CURRENT_TAB,
+ ui::PAGE_TRANSITION_TYPED, false),
+ /*navigation_handle_callback=*/{});
+
+ // Wait until the initial document commits and requests the subresource.
+ slow_response.WaitForRequest();
+
+ // 2. Before the first page completes loading or passage extraction,
+ // renavigate to a 404 page with new passages.
+ SetPagePassages({"Sample second page content"});
+
+ base::test::TestFuture<void> extraction_triggered;
+ SetExtractionCallback(extraction_triggered.GetRepeatingCallback());
+
+ GURL not_found_url = embedded_test_server()->GetURL("/non-existent-404");
+ EXPECT_TRUE(
+ content::ExecJs(browser()->tab_strip_model()->GetActiveWebContents(),
+ "location.href = '" + not_found_url.spec() + "'"));
+ std::ignore = content::WaitForLoadStop(
+ browser()->tab_strip_model()->GetActiveWebContents());
+
+ // Allow the slow subresource request to complete.
+ slow_response.Send(net::HTTP_NOT_FOUND);
+ slow_response.Done();
+
+ // Wait for content extraction to complete on the 404 page.
+ EXPECT_TRUE(extraction_triggered.Wait());
+
+ // Verify that the first URL is not associated with the second page's
+ // passages.
+ base::test::TestFuture<SearchResult> search_future;
+ service()->Search(nullptr, "second page content", {}, 1,
+ /*skip_answering=*/false,
+ /*url_id_filter=*/{}, search_future.GetRepeatingCallback());
+ SearchResult result = search_future.Take();
+
+ bool found_first_url_with_second_content = false;
+ for (const auto& scored_url_row : result.scored_url_rows) {
+ if (scored_url_row.row.url() == first_url) {
+ found_first_url_with_second_content = true;
+ break;
+ }
+ }
+ EXPECT_FALSE(found_first_url_with_second_content);
+}
+
+IN_PROC_BROWSER_TEST_F(
+ HistoryEmbeddingsBrowserTest,
+ SameDocumentNavigationFollowedByNotFoundDoesNotAssociateContent) {
+ OverrideVisibilityScoresForTesting({
+ {"Sample initial page content", 0.99},
+ {"Sample second page content", 0.99},
+ });
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ // 1. Load initial page.
+ SetPagePassages({"Sample initial page content"});
+ base::test::TestFuture<UrlData> initial_store_future;
+ service()->SetPassagesStoredCallbackForTesting(
+ initial_store_future.GetRepeatingCallback());
+
+ GURL first_url = embedded_test_server()->GetURL("/links.html");
+ EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), first_url));
+ EXPECT_TRUE(initial_store_future.Wait());
+
+ // 2. Perform a same-document navigation.
+ GURL pushed_url = embedded_test_server()->GetURL("/pushed_path");
+ EXPECT_TRUE(content::ExecJs(
+ browser()->tab_strip_model()->GetActiveWebContents(),
+ "history.pushState(null, '', '" + pushed_url.spec() + "')"));
+
+ service()->SetPassagesStoredCallbackForTesting(base::DoNothing());
+
+ // 3. Navigate to a 404 page with new passages.
+ SetPagePassages({"Sample second page content"});
+
+ base::test::TestFuture<void> extraction_triggered;
+ SetExtractionCallback(extraction_triggered.GetRepeatingCallback());
+
+ GURL not_found_url = embedded_test_server()->GetURL("/non-existent-404");
+ EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), not_found_url));
+
+ // Wait for content extraction to complete on the 404 page.
+ EXPECT_TRUE(extraction_triggered.Wait());
+
+ // Verify that neither the first URL nor pushed URL is associated with the
+ // second page's passages.
+ base::test::TestFuture<SearchResult> search_future;
+ service()->Search(nullptr, "second page content", {}, 1,
+ /*skip_answering=*/false,
+ /*url_id_filter=*/{}, search_future.GetRepeatingCallback());
+ SearchResult result = search_future.Take();
+
+ bool found_mismatched_url = false;
+ for (const auto& scored_url_row : result.scored_url_rows) {
+ if (scored_url_row.row.url() == first_url ||
+ scored_url_row.row.url() == pushed_url) {
+ found_mismatched_url = true;
+ break;
+ }
+ }
+ EXPECT_FALSE(found_mismatched_url);
}
} // namespace history_embeddings
Original Bug Report
Bypass in HistoryEmbeddingsTabHelper allows cross-origin embeddings spoofing
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 HistoryEmbeddingsTabHelper::DidFinishNavigation fails to fully invalidate internal state on cross-document navigations that result in a 404. This potentially allows an attacker to associate arbitrary page passages and embeddings with a cross-origin victim URL in the local history database. This can lead to persistent cross-origin content spoofing in history search results and on-device AI prompts.
Affected files:
chrome/browser/history_embeddings/history_embeddings_tab_helper.cc
Estimated timestamp from git blame: 2026-05-29
Description
A potential logic vulnerability exists in HistoryEmbeddingsTabHelper where the state-clearance mechanism is incomplete during cross-document navigations that result in an HTTP 404 status code.
When a 404 page commits, HistoryTabHelper::DidFinishNavigation (chrome/browser/history/history_tab_helper.cc:500) skips notifying its observers. Because of this, HistoryEmbeddingsTabHelper::OnUpdatedHistoryForNavigation is never invoked, leaving history_url_ and history_visit_time_ populated with the stale values from the previous navigation (e.g., a victim URL).
Additionally, the override HistoryEmbeddingsTabHelper::DidFinishNavigation (chrome/browser/history_embeddings/history_embeddings_tab_helper.cc:58-66) only erases last_history_visit_[web_contents] but does not cancel pending database lookup tasks or reset the history_url_ and history_visit_time_ members. As a result, when the attacker’s 404 page completes loading, DidFinishLoad triggers a database lookup for the stale victim URL, or an in-flight lookup from the victim’s load completes, writing the victim’s visit metadata back into the service’s tracking map. Subsequently, the attacker’s 404 page content embeddings are stored under the victim’s url_id in the history database.
Potential Attack Scenario
(Note: Our tooling agent does not currently have the capability to execute code; this sequence represents potential steps based on codebase analysis)
- A user visits
https://attacker.comand interacts with the page (granting transient user activation). - The attacker’s script opens a victim page in a new window/tab:
const w = window.open('https://victim.com/real-page');. - The victim page commits, and
HistoryEmbeddingsTabHelpersaves the victim’s URL and visit timeT_vin its member variables. - Before the victim’s history-thread database lookup completes or before its load finishes, the attacker redirects the window
wtohttps://attacker.com/poison(which returns an HTTP 404 with custom spoof passages in its body). - The 404 page commits, clearing the metadata map but leaving the stale
history_url_(victim GURL) andhistory_visit_time_(T_v) intact, and leaving any pending lookups uncancelled. - When the 404 page fires
DidFinishLoad, it either triggers a new query for the victim’s URL (Path A) or allows the victim’s original pending callback to finish (Path B). In both cases, the victim’s metadata is stored inlast_history_visit_[w]. - Once the page content embeddings for the attacker’s 404 page are computed,
HistoryEmbeddingsService::OnPageEmbeddingsAvailableretrieves the metadata mapped tow(which is the victim’s URL/visit ID) and stores the attacker’s spoofed passages under the victim’s entry in the history database.
Impact
An attacker can persistently overwrite the history embeddings database for any cross-origin URL with attacker-controlled text. This text then surfaces in chrome://history search snippets attributed to the victim origin and can be fed into the browser’s on-device AI prompt as trusted context from the victim. This logic flaw resides entirely in the browser process and does not require a renderer compromise.
Suggested Fix
Modify HistoryEmbeddingsTabHelper::DidFinishNavigation to fully invalidate the helper’s state on any cross-document commit by calling CancelHistoryLookup() and resetting the stale member variables:
void HistoryEmbeddingsTabHelper::DidFinishNavigation(
content::NavigationHandle* navigation_handle) {
if (navigation_handle->IsInPrimaryMainFrame() &&
navigation_handle->HasCommitted() &&
!navigation_handle->IsSameDocument()) {
CancelHistoryLookup();
history_visit_time_.reset();
history_url_.reset();
GetHistoryEmbeddingsService()->UpdateVisitMetadata(web_contents(),
std::nullopt);
}
}
Evaluated with Chrome root at commit: 87214e6721f6c34afd9181b80769a24c0c601c50
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.