Chrome · Glic
CVE-2026-106394
Logic Error in Glic
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/glic/glic_selection_observer.cc |
modified | |
GlicSelectionObserverCrossOriginNavigationTestchrome/browser/glic/glic_selection_observer_interactive_uitest.cc |
modified | |
GlicSelectionObserverCrossOriginNavigationTestchrome/browser/glic/glic_selection_observer_interactive_uitest.cc |
modified |
Files Changed
chrome/browser/glic/glic_selection_observer.ccchrome/browser/glic/glic_selection_observer.hchrome/test/data/glic/selection_origin_a.htmlchrome/browser/glic/glic_selection_observer_unittest.ccchrome/browser/glic/glic_selection_observer_interactive_uitest.ccchrome/test/data/glic/selection_origin_b.html
Patch
From 384629dd896151bf5160319bcaa81b2f74564638 Mon Sep 17 00:00:00 2001
From: Ian Vollick <vollick@chromium.org>
Date: Tue, 01 Sep 2026 06:35:55 -0700
Subject: [PATCH] Clear selection state on PrimaryPageChanged
Updates GlicSelectionObserver::PrimaryPageChanged to clear more state
so that we do not send incorrect updates after a navigation.
credit: fix and test adapted from suggestions by Project Fortify.
Bug: 513122002
Change-Id: Ic7b1425b249f3a99e28f8187320b3881c5f9ad90
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8323947
Reviewed-by: Dave Tapuska <dtapuska@chromium.org>
Commit-Queue: Dave Tapuska <dtapuska@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1689806}
---
diff --git a/chrome/browser/glic/glic_selection_observer.cc b/chrome/browser/glic/glic_selection_observer.cc
index 356e1bd..dcc7e45d 100644
--- a/chrome/browser/glic/glic_selection_observer.cc
+++ b/chrome/browser/glic/glic_selection_observer.cc
@@ -360,12 +360,7 @@
}
void GlicSelectionObserver::PrimaryPageChanged(content::Page& page) {
- is_hidden_on_current_page_ = false;
- UpdatePageBlockedState();
- if (widget_delegate_) {
- is_explaining_ = false;
- widget_delegate_->CloseWidget();
- }
+ ResetSelectionState();
}
void GlicSelectionObserver::PrimaryMainFrameWasResized(bool width_changed) {
@@ -717,6 +712,7 @@
if (selected_text.empty()) {
if (widget_delegate_ && !is_explaining_) {
widget_delegate_->CloseWidget();
+ generated_link_.reset();
}
if (has_sent_selection_context_) {
@@ -724,6 +720,7 @@
has_sent_selection_context_ = false;
}
+ last_selection_frame_token_.reset();
return;
}
@@ -1350,4 +1347,14 @@
last_direction_change_time_ = base::TimeTicks();
}
+void GlicSelectionObserver::ResetSelectionState() {
+ is_hidden_on_current_page_ = false;
+ is_explaining_ = false;
+ UpdatePageBlockedState();
+ // This should close the widget and clear most selection state.
+ UpdateSelectionState(u"", false);
+ // This should clear the rest.
+ ResetPendingSelection();
+}
+
} // namespace glic
diff --git a/chrome/browser/glic/glic_selection_observer.h b/chrome/browser/glic/glic_selection_observer.h
index a2fa2e6..4896997 100644
--- a/chrome/browser/glic/glic_selection_observer.h
+++ b/chrome/browser/glic/glic_selection_observer.h
@@ -190,6 +190,8 @@
std::string account,
optimization_guide::PageContextEligibility* page_context_eligibility);
+ void ResetSelectionState();
+
raw_ptr<GlicKeyedService> glic_keyed_service_;
base::CallbackListSubscription panel_state_subscription_;
std::u16string last_selected_text_;
diff --git a/chrome/browser/glic/glic_selection_observer_interactive_uitest.cc b/chrome/browser/glic/glic_selection_observer_interactive_uitest.cc
index 4e4ee8ba..44fa7518 100644
--- a/chrome/browser/glic/glic_selection_observer_interactive_uitest.cc
+++ b/chrome/browser/glic/glic_selection_observer_interactive_uitest.cc
@@ -4,14 +4,19 @@
#include "chrome/browser/glic/public/features.h"
#include "chrome/browser/glic/test_support/glic_browser_interactive_test.h"
+#include "chrome/browser/glic/test_support/interactive_test_util.h"
#include "chrome/browser/ui/browser_element_identifiers.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/browser/ui/views/frame/contents_web_view.h"
#include "chrome/test/interaction/interactive_browser_test.h"
#include "components/tabs/public/tab_interface.h"
#include "content/public/test/browser_test.h"
+#include "content/public/test/browser_test_utils.h"
+#include "net/dns/mock_host_resolver.h"
+#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
+#include "url/origin.h"
namespace glic {
namespace {
@@ -79,5 +84,109 @@
"Timeout waiting for selection context in Glic"));
}
+// TODO(b/537847327): This test should be migrated to use GlicApiBrowserTest,
+// and not the test client.
+class GlicSelectionObserverCrossOriginNavigationTest
+ : public GlicBrowserInteractiveTest {
+ public:
+ GlicSelectionObserverCrossOriginNavigationTest() {
+ glic_test_environment().SetGlicPagePath("/glic/test_client/index.html");
+ scoped_feature_list_.InitWithFeaturesAndParameters(
+ {{features::kGlicSelectionPrompt, {{"updates_only", "true"}}}}, {});
+ }
+
+ void SetUpOnMainThread() override {
+ host_resolver()->AddRule("*", "127.0.0.1");
+ GlicBrowserInteractiveTest::SetUpOnMainThread();
+ }
+
+ protected:
+ // Checks the additional context received by the web client and ensures that
+ // it does not context content from origin A.
+ static constexpr char kSawNoAdditionalContextFromOriginA[] = R"JS(
+ () => {
+ let c = document.querySelector('#additionalContextResult');
+ return !!c &&
+ !c.innerText.includes('application/x-glic-selection') &&
+ !c.innerText.includes('origin A content');
+ }
+ )JS";
+
+ auto SelectAll() {
+ return Do([this] {
+ content::WebContents* web_contents =
+ GetTabListInterface()->GetActiveTab()->GetContents();
+ web_contents->SelectAll();
+ });
+ }
+
+ auto Wait(base::TimeDelta timeout) {
+ auto observer = std::make_unique<test::internal::WaitingStateObserver>();
+ auto observer_ptr = observer.get();
+ return Steps(
+ Do(base::BindRepeating(
+ [](test::internal::WaitingStateObserver* observer,
+ base::TimeDelta timeout) { observer->Start(timeout); },
+ base::Unretained(observer_ptr), timeout)),
+ ObserveState(glic::test::internal::kDelayState, std::move(observer)),
+ WaitForState(glic::test::internal::kDelayState, true));
+ }
+
+ private:
+ base::test::ScopedFeatureList scoped_feature_list_;
+};
+
+IN_PROC_BROWSER_TEST_F(GlicSelectionObserverCrossOriginNavigationTest,
+ DoesNotShowStaleSelectionUi) {
+ DEFINE_LOCAL_ELEMENT_IDENTIFIER_VALUE(kActiveTab);
+
+ const GURL url_a =
+ embedded_test_server()->GetURL("a.test", "/glic/selection_origin_a.html");
+ const GURL url_b =
+ embedded_test_server()->GetURL("b.test", "/glic/selection_origin_b.html");
+ ASSERT_NE(url::Origin::Create(url_a), url::Origin::Create(url_b));
+
+ const DeepQuery kPathToBody{"body"};
+
+ RunTestSequence(
+ InstrumentTab(kActiveTab),
+
+ // Step 1: Load origin A and let the user "select" text.
+ // The mouse clicks ensure is_key_selection_=false so the selection is
+ // accepted; the Wait covers the 200ms debounce so last_selected_text_
+ // is populated with content from origin A.
+ NavigateWebContents(kActiveTab, url_a),
+ WaitForWebContentsPainted(kActiveTab),
+ MoveMouseTo(kActiveTab, kPathToBody), ClickMouse(), ClickMouse(),
+ SelectAll(), Wait(base::Milliseconds(400)),
+
+ // Step 2: Cross-document, cross-origin navigation to origin B in the
+ // same tab. PrimaryPageChanged() clears state, ensuring that we will not
+ // show stale selection UI in the web client.
+ NavigateWebContents(kActiveTab, url_b),
+ WaitForWebContentsPainted(kActiveTab));
+
+ // Step 3: Open the Glic panel *without touching origin B's content*. If we
+ // did not properly clear state, then OnGlobalPanelShowHide() re-ships
+ // last_selected_text_ via SendAdditionalContext(), tagged with the current
+ // tab handle.
+ ASSERT_OK_AND_ASSIGN(auto* instance, OpenGlicForActiveTab());
+ ASSERT_OK(WaitForGlicClient(instance));
+
+ // Step 4: Ensure that we did not get a selection sent to the client. If we
+ // had not cleared correctly, we would have additional context from origin A,
+ // which is incorrect since we have navigated to origin B.
+ EXPECT_TRUE(RunUntil(
+ [&]() {
+ auto* guest_frame = instance->host().GetGuestMainFrame();
+ return guest_frame &&
+ content::EvalJs(
+ guest_frame,
+ base::StrCat({"(", kSawNoAdditionalContextFromOriginA,
+ ")()"})) == true;
+ },
+ "Timeout waiting for selection context in Glic"));
+}
+
} // namespace
} // namespace glic
diff --git a/chrome/browser/glic/glic_selection_observer_unittest.cc b/chrome/browser/glic/glic_selection_observer_unittest.cc
index 0a2b9164..878286a 100644
--- a/chrome/browser/glic/glic_selection_observer_unittest.cc
+++ b/chrome/browser/glic/glic_selection_observer_unittest.cc
@@ -1109,6 +1109,11 @@
observer->OnPageContextEligibilityChanged(
optimization_guide::PageContextEligibilityStatus::kEligible);
+ // We have to reset the selected text here because setting mock eligibility
+ // causes a navigation which clears the previous selected text.
+ observer->OnTextSelectionChanged(nullptr, u"Selected Text");
+ task_environment()->FastForwardBy(base::Milliseconds(300));
+
EXPECT_TRUE(observer->send_context_called());
EXPECT_EQ(u"Selected Text", *observer->last_sent_context());
}
diff --git a/chrome/test/data/glic/selection_origin_a.html b/chrome/test/data/glic/selection_origin_a.html
new file mode 100644
index 0000000..b764182
--- /dev/null
+++ b/chrome/test/data/glic/selection_origin_a.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<!--
+
+Copyright 2026 The Chromium Authors
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+
+-->
+<html>
+<head><title>Origin A</title></head>
+<body>origin A content</body>
+</html>
diff --git a/chrome/test/data/glic/selection_origin_b.html b/chrome/test/data/glic/selection_origin_b.html
new file mode 100644
index 0000000..ee4eea2
--- /dev/null
+++ b/chrome/test/data/glic/selection_origin_b.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<!--
+
+Copyright 2026 The Chromium Authors
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+
+-->
+<html>
+<head><title>Origin B</title></head>
+<body>origin B content</body>
+</html>
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/chrome/browser/glic/glic_selection_observer_interactive_uitest.cc b/chrome/browser/glic/glic_selection_observer_interactive_uitest.cc
index 4e4ee8ba..44fa7518 100644
--- a/chrome/browser/glic/glic_selection_observer_interactive_uitest.cc
+++ b/chrome/browser/glic/glic_selection_observer_interactive_uitest.cc
@@ -4,14 +4,19 @@
#include "chrome/browser/glic/public/features.h"
#include "chrome/browser/glic/test_support/glic_browser_interactive_test.h"
+#include "chrome/browser/glic/test_support/interactive_test_util.h"
#include "chrome/browser/ui/browser_element_identifiers.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/browser/ui/views/frame/contents_web_view.h"
#include "chrome/test/interaction/interactive_browser_test.h"
#include "components/tabs/public/tab_interface.h"
#include "content/public/test/browser_test.h"
+#include "content/public/test/browser_test_utils.h"
+#include "net/dns/mock_host_resolver.h"
+#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
+#include "url/origin.h"
namespace glic {
namespace {
@@ -79,5 +84,109 @@
"Timeout waiting for selection context in Glic"));
}
+// TODO(b/537847327): This test should be migrated to use GlicApiBrowserTest,
+// and not the test client.
+class GlicSelectionObserverCrossOriginNavigationTest
+ : public GlicBrowserInteractiveTest {
+ public:
+ GlicSelectionObserverCrossOriginNavigationTest() {
+ glic_test_environment().SetGlicPagePath("/glic/test_client/index.html");
+ scoped_feature_list_.InitWithFeaturesAndParameters(
+ {{features::kGlicSelectionPrompt, {{"updates_only", "true"}}}}, {});
+ }
+
+ void SetUpOnMainThread() override {
+ host_resolver()->AddRule("*", "127.0.0.1");
+ GlicBrowserInteractiveTest::SetUpOnMainThread();
+ }
+
+ protected:
+ // Checks the additional context received by the web client and ensures that
+ // it does not context content from origin A.
+ static constexpr char kSawNoAdditionalContextFromOriginA[] = R"JS(
+ () => {
+ let c = document.querySelector('#additionalContextResult');
+ return !!c &&
+ !c.innerText.includes('application/x-glic-selection') &&
+ !c.innerText.includes('origin A content');
+ }
+ )JS";
+
+ auto SelectAll() {
+ return Do([this] {
+ content::WebContents* web_contents =
+ GetTabListInterface()->GetActiveTab()->GetContents();
+ web_contents->SelectAll();
+ });
+ }
+
+ auto Wait(base::TimeDelta timeout) {
+ auto observer = std::make_unique<test::internal::WaitingStateObserver>();
+ auto observer_ptr = observer.get();
+ return Steps(
+ Do(base::BindRepeating(
+ [](test::internal::WaitingStateObserver* observer,
+ base::TimeDelta timeout) { observer->Start(timeout); },
+ base::Unretained(observer_ptr), timeout)),
+ ObserveState(glic::test::internal::kDelayState, std::move(observer)),
+ WaitForState(glic::test::internal::kDelayState, true));
+ }
+
+ private:
+ base::test::ScopedFeatureList scoped_feature_list_;
+};
+
+IN_PROC_BROWSER_TEST_F(GlicSelectionObserverCrossOriginNavigationTest,
+ DoesNotShowStaleSelectionUi) {
+ DEFINE_LOCAL_ELEMENT_IDENTIFIER_VALUE(kActiveTab);
+
+ const GURL url_a =
+ embedded_test_server()->GetURL("a.test", "/glic/selection_origin_a.html");
+ const GURL url_b =
+ embedded_test_server()->GetURL("b.test", "/glic/selection_origin_b.html");
+ ASSERT_NE(url::Origin::Create(url_a), url::Origin::Create(url_b));
+
+ const DeepQuery kPathToBody{"body"};
+
+ RunTestSequence(
+ InstrumentTab(kActiveTab),
+
+ // Step 1: Load origin A and let the user "select" text.
+ // The mouse clicks ensure is_key_selection_=false so the selection is
+ // accepted; the Wait covers the 200ms debounce so last_selected_text_
+ // is populated with content from origin A.
+ NavigateWebContents(kActiveTab, url_a),
+ WaitForWebContentsPainted(kActiveTab),
+ MoveMouseTo(kActiveTab, kPathToBody), ClickMouse(), ClickMouse(),
+ SelectAll(), Wait(base::Milliseconds(400)),
+
+ // Step 2: Cross-document, cross-origin navigation to origin B in the
+ // same tab. PrimaryPageChanged() clears state, ensuring that we will not
+ // show stale selection UI in the web client.
+ NavigateWebContents(kActiveTab, url_b),
+ WaitForWebContentsPainted(kActiveTab));
+
+ // Step 3: Open the Glic panel *without touching origin B's content*. If we
+ // did not properly clear state, then OnGlobalPanelShowHide() re-ships
+ // last_selected_text_ via SendAdditionalContext(), tagged with the current
+ // tab handle.
+ ASSERT_OK_AND_ASSIGN(auto* instance, OpenGlicForActiveTab());
+ ASSERT_OK(WaitForGlicClient(instance));
+
+ // Step 4: Ensure that we did not get a selection sent to the client. If we
+ // had not cleared correctly, we would have additional context from origin A,
+ // which is incorrect since we have navigated to origin B.
+ EXPECT_TRUE(RunUntil(
+ [&]() {
+ auto* guest_frame = instance->host().GetGuestMainFrame();
+ return guest_frame &&
+ content::EvalJs(
+ guest_frame,
+ base::StrCat({"(", kSawNoAdditionalContextFromOriginA,
+ ")()"})) == true;
+ },
+ "Timeout waiting for selection context in Glic"));
+}
+
} // namespace
} // namespace glic
diff --git a/chrome/browser/glic/glic_selection_observer_unittest.cc b/chrome/browser/glic/glic_selection_observer_unittest.cc
index 0a2b9164..878286a 100644
--- a/chrome/browser/glic/glic_selection_observer_unittest.cc
+++ b/chrome/browser/glic/glic_selection_observer_unittest.cc
@@ -1109,6 +1109,11 @@
observer->OnPageContextEligibilityChanged(
optimization_guide::PageContextEligibilityStatus::kEligible);
+ // We have to reset the selected text here because setting mock eligibility
+ // causes a navigation which clears the previous selected text.
+ observer->OnTextSelectionChanged(nullptr, u"Selected Text");
+ task_environment()->FastForwardBy(base::Milliseconds(300));
+
EXPECT_TRUE(observer->send_context_called());
EXPECT_EQ(u"Selected Text", *observer->last_sent_context());
}
diff --git a/chrome/test/data/glic/selection_origin_a.html b/chrome/test/data/glic/selection_origin_a.html
new file mode 100644
index 0000000..b764182
--- /dev/null
+++ b/chrome/test/data/glic/selection_origin_a.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<!--
+
+Copyright 2026 The Chromium Authors
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+
+-->
+<html>
+<head><title>Origin A</title></head>
+<body>origin A content</body>
+</html>
diff --git a/chrome/test/data/glic/selection_origin_b.html b/chrome/test/data/glic/selection_origin_b.html
new file mode 100644
index 0000000..ee4eea2
--- /dev/null
+++ b/chrome/test/data/glic/selection_origin_b.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<!--
+
+Copyright 2026 The Chromium Authors
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+
+-->
+<html>
+<head><title>Origin B</title></head>
+<body>origin B content</body>
+</html>
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page