CVE-2026-87498
Overview
Background
- Reading Mode / Read Anything
- a Chrome side-panel accessibility feature backed by the WebUI class
ReadAnythingUntrustedPageHandler, which services Mojo IPC calls from an untrusted (chrome-untrusted://) renderer-hosted page. - Mojo `UntrustedPageHandler`
- the browser-side Mojo interface (
read_anything::mojom::UntrustedPageHandler) whose methods (OnCopy,OnLinkClicked,OnSelectionChange,OnCollapseSelection) the untrusted page can invoke to act on the main tab’sWebContents. - Transient user activation
- a short-lived, per-frame flag (
RenderFrameHost::HasTransientUserActivation()) set by a genuine user gesture (e.g. a click) and consumed shortly after, used to distinguish real user actions from script-initiated ones. - `main_observer_`
- the handler’s observer holding the main browser tab’s
web_contents(), on which the privileged actions (Copy(),CollapseSelection(), link navigation) are performed.
Root Cause Analysis
The Mojo methods ReadAnythingUntrustedPageHandler::OnCopy, OnLinkClicked, OnSelectionChange, and OnCollapseSelection acted on the main tab’s WebContents without verifying that the incoming IPC corresponded to a genuine user gesture. Because these endpoints are exposed to the untrusted Reading Mode page, a script in that page (rather than a real user click) could invoke them directly and drive privileged actions — copying content to the clipboard, collapsing/changing the main-page selection, or triggering OnLinkClicked link handling — as the commit message states, “via a script or something other than user action.” The invariant violated is that these renderer-reachable commands should only fire in response to real user activation.
The fix adds a new helper HasTransientUserActivation() and an early guard (if (!HasTransientUserActivation()) { VLOG(1) << ...; return; }) at the top of each of the four methods, so the privileged body runs only when the WebUI frame’s RenderFrameHost reports transient user activation. The helper is also null-safe: it checks web_ui_ and web_ui_->GetRenderFrameHost() before dereferencing, so a null RenderFrameHost returns false and the method returns early rather than crashing.
HasTransientUserActivation(), dropping any call that lacks transient user activation.Attack Path
- Reach the untrusted handler
The Reading Mode side panel loads a
chrome-untrusted://page that is bound to the browser-sideread_anything::mojom::UntrustedPageHandlerMojo interface implemented byReadAnythingUntrustedPageHandler. - Invoke a command without a gesture
Script on that page calls a Mojo endpoint such as
OnLinkClicked,OnCopy,OnSelectionChange, orOnCollapseSelectionprogrammatically, with no user click, since the pre-patch code had no user-activation check. - Privileged action executes on the main tab
The unguarded method reaches its body and acts on
main_observer_->web_contents()— e.g.OnCopycallsCopy()(writing to the clipboard),OnCollapseSelectioncallsCollapseSelection(), andOnLinkClickedproceeds into its link-handling path — all without user intent. - Downstream effect (inferred)
Any further consequence of the spoofed link click or clipboard/selection manipulation is
(inferred); the diff shows only that the actions could be triggered scriptlessly, not a concrete memory-corruption or code-execution sink.
Impact Assessment
OnLinkClicked navigation or clipboard writes into a broader compromise — is (inferred) and not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc |
modified | |
ASSERT_TRUEchrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc |
modified |
Files Changed
chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.hchrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.ccchrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc
Audit Directions
- Ungated WebUI/Mojo endpointsAudit other
*UntrustedPageHandler(and WebUI page-handler) Mojo methods that mutate the mainWebContents, clipboard, selection, or navigation for a missingHasTransientUserActivation()(or equivalent gesture) check. - Trust boundary at `chrome-untrusted://`Review handlers reachable from
chrome-untrusted://pages, treating every exposed method as callable by arbitrary script and requiring an explicit authorization gate for any privileged action. - Null-safety on frame accessLook for handler code that dereferences
web_ui_->GetRenderFrameHost()(ormain_observer_->web_contents()) without null-checking, as the newHasTransientUserActivation()deliberately guards bothweb_ui_and theRenderFrameHost.
Patch
From 06262fce88f70dc902d29f6be3dbb8fb140a9432 Mon Sep 17 00:00:00 2001
From: Lauren Winston <lwinston@google.com>
Date: Tue, 25 Aug 2026 21:03:10 -0700
Subject: [PATCH] Add missing user activation checks to the UntrustedPageHandler.
This prevents Mojo commands from being executed (e.g.
OnLinkClicked) via a script or something other than
user action.
Fixed:552413517
Change-Id: I9fac4ea6c74b4454d5fe6b2c92ddfbb0477287a9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8282642
Commit-Queue: Lauren Winston <lwinston@google.com>
Reviewed-by: Kristi Saney <kristislee@google.com>
Cr-Commit-Position: refs/heads/main@{#1686136}
---
diff --git a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc
index 189b752..87b9e1e 100644
--- a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc
+++ b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc
@@ -838,6 +838,11 @@
}
void ReadAnythingUntrustedPageHandler::OnCopy() {
+ if (!HasTransientUserActivation()) {
+ VLOG(1) << "OnCopy failed with no transient user activation";
+ return;
+ }
+
if (main_observer_ && main_observer_->web_contents()) {
main_observer_->web_contents()->Copy();
}
@@ -1098,6 +1103,11 @@
void ReadAnythingUntrustedPageHandler::OnLinkClicked(
const ui::AXTreeID& target_tree_id,
ui::AXNodeID target_node_id) {
+ if (!HasTransientUserActivation()) {
+ VLOG(1) << "OnLinkClicked failed with no transient user activation";
+ return;
+ }
+
bool is_observing_tree = IsObservingTree(target_tree_id);
base::UmaHistogramBoolean(kRendererLinkRequestHistogram, is_observing_tree);
if (!is_observing_tree) {
@@ -1285,6 +1295,11 @@
int anchor_offset,
ui::AXNodeID focus_node_id,
int focus_offset) {
+ if (!HasTransientUserActivation()) {
+ VLOG(1) << "OnSelectionChange failed with no transient user activation";
+ return;
+ }
+
bool is_observing_tree = IsObservingTree(target_tree_id);
base::UmaHistogramBoolean(kRendererSelectionRequestHistogram,
is_observing_tree);
@@ -1315,6 +1330,11 @@
}
void ReadAnythingUntrustedPageHandler::OnCollapseSelection() {
+ if (!HasTransientUserActivation()) {
+ VLOG(1) << "OnCollapseSelection failed with no transient user activation";
+ return;
+ }
+
if (main_observer_ && main_observer_->web_contents()) {
main_observer_->web_contents()->CollapseSelection();
}
@@ -2069,3 +2089,8 @@
}
}
#endif
+
+bool ReadAnythingUntrustedPageHandler::HasTransientUserActivation() const {
+ return web_ui_ && web_ui_->GetRenderFrameHost() &&
+ web_ui_->GetRenderFrameHost()->HasTransientUserActivation();
+}
diff --git a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h
index 9f31e76f..8cc140de 100644
--- a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h
+++ b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h
@@ -386,6 +386,8 @@
content::WebContents* GetWebContents() const;
+ bool HasTransientUserActivation() const;
+
// Returns the actual language of the text currently displayed in the Reading
// Mode panel.
std::string GetDisplayLanguage();
diff --git a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
index 037caced..761e053 100644
--- a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
+++ b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
@@ -47,6 +47,7 @@
#include "components/translate/core/browser/translate_manager.h"
#include "components/user_education/common/new_badge/new_badge_specification.h"
#include "components/user_education/common/user_education_features.h"
+#include "content/public/browser/render_widget_host_view.h"
#include "content/public/common/content_switches.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
@@ -66,6 +67,9 @@
#include "ui/accessibility/mojom/ax_tree_update.mojom.h"
#include "ui/accessibility/platform/browser_accessibility.h"
#include "ui/accessibility/platform/browser_accessibility_manager.h"
+#include "ui/base/clipboard/clipboard.h"
+#include "ui/base/clipboard/clipboard_buffer.h"
+#include "ui/base/clipboard/clipboard_sequence_number_token.h"
#include "ui/gfx/geometry/size.h"
#if BUILDFLAG(IS_CHROMEOS)
#include "base/test/bind.h"
@@ -192,23 +196,25 @@
#if BUILDFLAG(IS_CHROMEOS)
explicit TestReadAnythingUntrustedPageHandler(
mojo::PendingRemote<read_anything::mojom::UntrustedPage> page,
+ mojo::PendingReceiver<read_anything::mojom::UntrustedPageHandler>
+ receiver,
content::WebUI* test_web_ui,
std::unique_ptr<ChromeOsExtensionWrapper> extension_wrapper)
- : ReadAnythingUntrustedPageHandler(
- std::move(page),
- mojo::PendingReceiver<read_anything::mojom::UntrustedPageHandler>(),
- test_web_ui,
- /*use_screen_ai_service=*/false,
- std::move(extension_wrapper)) {}
+ : ReadAnythingUntrustedPageHandler(std::move(page),
+ std::move(receiver),
+ test_web_ui,
+ /*use_screen_ai_service=*/false,
+ std::move(extension_wrapper)) {}
#else
explicit TestReadAnythingUntrustedPageHandler(
mojo::PendingRemote<read_anything::mojom::UntrustedPage> page,
+ mojo::PendingReceiver<read_anything::mojom::UntrustedPageHandler>
+ receiver,
content::WebUI* test_web_ui)
- : ReadAnythingUntrustedPageHandler(
- std::move(page),
- mojo::PendingReceiver<read_anything::mojom::UntrustedPageHandler>(),
- test_web_ui,
- /*use_screen_ai_service=*/false) {}
+ : ReadAnythingUntrustedPageHandler(std::move(page),
+ std::move(receiver),
+ test_web_ui,
+ /*use_screen_ai_service=*/false) {}
#endif
};
@@ -301,6 +307,7 @@
#if BUILDFLAG(IS_CHROMEOS)
extension_wrapper_ptr_ = nullptr;
#endif
+ handler_remote_.reset();
handler_.reset();
test_web_ui_.reset();
web_contents_.reset();
@@ -308,17 +315,19 @@
}
std::unique_ptr<TestReadAnythingUntrustedPageHandler> CreateHandler() {
+ handler_remote_.reset();
#if BUILDFLAG(IS_CHROMEOS)
std::unique_ptr<ChromeOsExtensionWrapper> extension_wrapper_mock =
std::make_unique<testing::NiceMock<MockChromeOsExtensionWrapper>>();
extension_wrapper_ptr_ = static_cast<MockChromeOsExtensionWrapper*>(
extension_wrapper_mock.get());
return std::make_unique<TestReadAnythingUntrustedPageHandler>(
- page_.BindAndGetRemote(), test_web_ui_.get(),
- std::move(extension_wrapper_mock));
+ page_.BindAndGetRemote(), handler_remote_.BindNewPipeAndPassReceiver(),
+ test_web_ui_.get(), std::move(extension_wrapper_mock));
#else
return std::make_unique<TestReadAnythingUntrustedPageHandler>(
- page_.BindAndGetRemote(), test_web_ui_.get());
+ page_.BindAndGetRemote(), handler_remote_.BindNewPipeAndPassReceiver(),
+ test_web_ui_.get());
#endif
}
@@ -484,6 +493,7 @@
#endif
testing::NiceMock<MockPage> page_;
FakeTtsEngineDelegate engine_delegate_;
+ mojo::Remote<read_anything::mojom::UntrustedPageHandler> handler_remote_;
std::unique_ptr<ReadAnythingUntrustedPageHandler> handler_;
std::unique_ptr<content::WebContents> web_contents_;
std::unique_ptr<content::TestWebUI> test_web_ui_;
@@ -1434,8 +1444,8 @@
EXPECT_CALL(*extension_wrapper_ptr_, ActivateSpeechEngine).Times(1);
handler_ = std::make_unique<TestReadAnythingUntrustedPageHandler>(
- page_.BindAndGetRemote(), test_web_ui_.get(),
- std::move(extension_wrapper_mock));
+ page_.BindAndGetRemote(), handler_remote_.BindNewPipeAndPassReceiver(),
+ test_web_ui_.get(), std::move(extension_wrapper_mock));
}
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -2440,4 +2450,187 @@
EXPECT_FALSE(handler_->dom_distiller_content().has_value());
}
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ OnLinkClicked_RequiresTransientUserActivation) {
+ const std::string histogram_name =
+ "Accessibility.ReadAnything.RendererRequestForLinkClick."
+ "IsFromObservedTree";
+ base::HistogramTester histogram_tester;
+
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+ content::RenderFrameHost* main_rfh =
+ browser()->GetActiveTabInterface()->GetContents()->GetPrimaryMainFrame();
+ ui::AXTreeID tree_id = main_rfh->GetAXTreeID();
+ ui::AXNodeID node_id = 1;
+
+ // Initial state with no transient user activation
+ EXPECT_FALSE(rfh->HasTransientUserActivation());
+ handler_remote_->OnLinkClicked(tree_id, node_id);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectTotalCount(histogram_name, 0);
+
+ // Grant User Activation via simulated mouse click.
+ content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+ content::SimulateMouseClick(web_contents_.get(), 0,
+ blink::WebMouseEvent::Button::kLeft);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return rfh->HasTransientUserActivation(); }));
+ EXPECT_TRUE(rfh->HasTransientUserActivation());
+ handler_remote_->OnLinkClicked(tree_id, node_id);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(histogram_name, /*sample=*/true,
+ /*expected_bucket_count=*/1);
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ OnSelectionChange_RequiresTransientUserActivation) {
+ const std::string histogram_name =
+ "Accessibility.ReadAnything.RendererRequestForSelection."
+ "IsFromObservedTree";
+ base::HistogramTester histogram_tester;
+
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+ content::RenderFrameHost* main_rfh =
+ browser()->GetActiveTabInterface()->GetContents()->GetPrimaryMainFrame();
+ ui::AXTreeID tree_id = main_rfh->GetAXTreeID();
+
+ // Initial state with no transient user activation
+ EXPECT_FALSE(rfh->HasTransientUserActivation());
+ handler_remote_->OnSelectionChange(tree_id, /*anchor_node_id=*/1, 0,
+ /*focus_node_id=*/2, 5);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectTotalCount(histogram_name, 0);
+
+ // Grant User Activation via simulated mouse click.
+ content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+ content::SimulateMouseClick(web_contents_.get(), 0,
+ blink::WebMouseEvent::Button::kLeft);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return rfh->HasTransientUserActivation(); }));
+ EXPECT_TRUE(rfh->HasTransientUserActivation());
+ handler_remote_->OnSelectionChange(tree_id, /*anchor_node_id=*/1, 0,
+ /*focus_node_id=*/2, 5);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(histogram_name, /*sample=*/true,
+ /*expected_bucket_count=*/1);
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ NullRenderFrameHost_ReturnsSafely) {
+ test_web_ui_->set_render_frame_host(nullptr);
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* main_rfh =
+ browser()->GetActiveTabInterface()->GetContents()->GetPrimaryMainFrame();
+ ui::AXTreeID tree_id = main_rfh->GetAXTreeID();
+
+ handler_remote_->OnCopy();
+ handler_remote_->OnLinkClicked(tree_id, 1);
+ handler_remote_->OnSelectionChange(tree_id, 1, 0, 1, 1);
+ handler_remote_->OnCollapseSelection();
+ handler_remote_.FlushForTesting();
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ OnCopy_RequiresTransientUserActivation) {
+ content::WebContents* main_contents =
+ browser()->GetActiveTabInterface()->GetContents();
+ ASSERT_TRUE(content::NavigateToURL(
+ main_contents, GURL("data:text/html,<div>Hello World</div>")));
+ main_contents->Focus();
+ main_contents->SelectAll();
+
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ content::RenderWidgetHostView* view =
+ main_contents->GetRenderWidgetHostView();
+ return view && !view->GetSelectedText().empty();
+ }));
+
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+ ui::Clipboard* clipboard = ui::Clipboard::GetForCurrentThread();
+ const ui::ClipboardSequenceNumberToken initial_seq =
+ clipboard->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste);
+
+ // Initial state with no transient user activation
+ EXPECT_FALSE(rfh->HasTransientUserActivation());
+ handler_remote_->OnCopy();
+ handler_remote_.FlushForTesting();
+ EXPECT_EQ(clipboard->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste),
+ initial_seq);
+
+ // Grant User Activation via simulated mouse click.
+ content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+ content::SimulateMouseClick(web_contents_.get(), 0,
+ blink::WebMouseEvent::Button::kLeft);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return rfh->HasTransientUserActivation(); }));
+
+ handler_remote_->OnCopy();
+ handler_remote_.FlushForTesting();
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ return clipboard->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste) !=
+ initial_seq;
+ }));
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ OnCollapseSelection_RequiresTransientUserActivation) {
+ content::WebContents* main_contents =
+ browser()->GetActiveTabInterface()->GetContents();
+ ASSERT_TRUE(content::NavigateToURL(
+ main_contents, GURL("data:text/html,<div>Selected Text</div>")));
+ main_contents->Focus();
+ main_contents->SelectAll();
+
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ content::RenderWidgetHostView* view =
+ main_contents->GetRenderWidgetHostView();
+ return view && !view->GetSelectedText().empty();
+ }));
+
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+
+ // Initial state with no transient user activation
+ EXPECT_FALSE(rfh->HasTransientUserActivation());
+ handler_remote_->OnCollapseSelection();
+ handler_remote_.FlushForTesting();
+ EXPECT_FALSE(
+ main_contents->GetRenderWidgetHostView()->GetSelectedText().empty());
+
+ // Grant User Activation via simulated mouse click.
+ content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+ content::SimulateMouseClick(web_contents_.get(), 0,
+ blink::WebMouseEvent::Button::kLeft);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return rfh->HasTransientUserActivation(); }));
+
+ handler_remote_->OnCollapseSelection();
+ handler_remote_.FlushForTesting();
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ content::RenderWidgetHostView* view =
+ main_contents->GetRenderWidgetHostView();
+ return view && view->GetSelectedText().empty();
+ }));
+}
+
} // namespace
Regression Test / PoC
diff --git a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
index 037caced..761e053 100644
--- a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
+++ b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
@@ -47,6 +47,7 @@
#include "components/translate/core/browser/translate_manager.h"
#include "components/user_education/common/new_badge/new_badge_specification.h"
#include "components/user_education/common/user_education_features.h"
+#include "content/public/browser/render_widget_host_view.h"
#include "content/public/common/content_switches.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
@@ -66,6 +67,9 @@
#include "ui/accessibility/mojom/ax_tree_update.mojom.h"
#include "ui/accessibility/platform/browser_accessibility.h"
#include "ui/accessibility/platform/browser_accessibility_manager.h"
+#include "ui/base/clipboard/clipboard.h"
+#include "ui/base/clipboard/clipboard_buffer.h"
+#include "ui/base/clipboard/clipboard_sequence_number_token.h"
#include "ui/gfx/geometry/size.h"
#if BUILDFLAG(IS_CHROMEOS)
#include "base/test/bind.h"
@@ -192,23 +196,25 @@
#if BUILDFLAG(IS_CHROMEOS)
explicit TestReadAnythingUntrustedPageHandler(
mojo::PendingRemote<read_anything::mojom::UntrustedPage> page,
+ mojo::PendingReceiver<read_anything::mojom::UntrustedPageHandler>
+ receiver,
content::WebUI* test_web_ui,
std::unique_ptr<ChromeOsExtensionWrapper> extension_wrapper)
- : ReadAnythingUntrustedPageHandler(
- std::move(page),
- mojo::PendingReceiver<read_anything::mojom::UntrustedPageHandler>(),
- test_web_ui,
- /*use_screen_ai_service=*/false,
- std::move(extension_wrapper)) {}
+ : ReadAnythingUntrustedPageHandler(std::move(page),
+ std::move(receiver),
+ test_web_ui,
+ /*use_screen_ai_service=*/false,
+ std::move(extension_wrapper)) {}
#else
explicit TestReadAnythingUntrustedPageHandler(
mojo::PendingRemote<read_anything::mojom::UntrustedPage> page,
+ mojo::PendingReceiver<read_anything::mojom::UntrustedPageHandler>
+ receiver,
content::WebUI* test_web_ui)
- : ReadAnythingUntrustedPageHandler(
- std::move(page),
- mojo::PendingReceiver<read_anything::mojom::UntrustedPageHandler>(),
- test_web_ui,
- /*use_screen_ai_service=*/false) {}
+ : ReadAnythingUntrustedPageHandler(std::move(page),
+ std::move(receiver),
+ test_web_ui,
+ /*use_screen_ai_service=*/false) {}
#endif
};
@@ -301,6 +307,7 @@
#if BUILDFLAG(IS_CHROMEOS)
extension_wrapper_ptr_ = nullptr;
#endif
+ handler_remote_.reset();
handler_.reset();
test_web_ui_.reset();
web_contents_.reset();
@@ -308,17 +315,19 @@
}
std::unique_ptr<TestReadAnythingUntrustedPageHandler> CreateHandler() {
+ handler_remote_.reset();
#if BUILDFLAG(IS_CHROMEOS)
std::unique_ptr<ChromeOsExtensionWrapper> extension_wrapper_mock =
std::make_unique<testing::NiceMock<MockChromeOsExtensionWrapper>>();
extension_wrapper_ptr_ = static_cast<MockChromeOsExtensionWrapper*>(
extension_wrapper_mock.get());
return std::make_unique<TestReadAnythingUntrustedPageHandler>(
- page_.BindAndGetRemote(), test_web_ui_.get(),
- std::move(extension_wrapper_mock));
+ page_.BindAndGetRemote(), handler_remote_.BindNewPipeAndPassReceiver(),
+ test_web_ui_.get(), std::move(extension_wrapper_mock));
#else
return std::make_unique<TestReadAnythingUntrustedPageHandler>(
- page_.BindAndGetRemote(), test_web_ui_.get());
+ page_.BindAndGetRemote(), handler_remote_.BindNewPipeAndPassReceiver(),
+ test_web_ui_.get());
#endif
}
@@ -484,6 +493,7 @@
#endif
testing::NiceMock<MockPage> page_;
FakeTtsEngineDelegate engine_delegate_;
+ mojo::Remote<read_anything::mojom::UntrustedPageHandler> handler_remote_;
std::unique_ptr<ReadAnythingUntrustedPageHandler> handler_;
std::unique_ptr<content::WebContents> web_contents_;
std::unique_ptr<content::TestWebUI> test_web_ui_;
@@ -1434,8 +1444,8 @@
EXPECT_CALL(*extension_wrapper_ptr_, ActivateSpeechEngine).Times(1);
handler_ = std::make_unique<TestReadAnythingUntrustedPageHandler>(
- page_.BindAndGetRemote(), test_web_ui_.get(),
- std::move(extension_wrapper_mock));
+ page_.BindAndGetRemote(), handler_remote_.BindNewPipeAndPassReceiver(),
+ test_web_ui_.get(), std::move(extension_wrapper_mock));
}
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -2440,4 +2450,187 @@
EXPECT_FALSE(handler_->dom_distiller_content().has_value());
}
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ OnLinkClicked_RequiresTransientUserActivation) {
+ const std::string histogram_name =
+ "Accessibility.ReadAnything.RendererRequestForLinkClick."
+ "IsFromObservedTree";
+ base::HistogramTester histogram_tester;
+
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+ content::RenderFrameHost* main_rfh =
+ browser()->GetActiveTabInterface()->GetContents()->GetPrimaryMainFrame();
+ ui::AXTreeID tree_id = main_rfh->GetAXTreeID();
+ ui::AXNodeID node_id = 1;
+
+ // Initial state with no transient user activation
+ EXPECT_FALSE(rfh->HasTransientUserActivation());
+ handler_remote_->OnLinkClicked(tree_id, node_id);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectTotalCount(histogram_name, 0);
+
+ // Grant User Activation via simulated mouse click.
+ content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+ content::SimulateMouseClick(web_contents_.get(), 0,
+ blink::WebMouseEvent::Button::kLeft);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return rfh->HasTransientUserActivation(); }));
+ EXPECT_TRUE(rfh->HasTransientUserActivation());
+ handler_remote_->OnLinkClicked(tree_id, node_id);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(histogram_name, /*sample=*/true,
+ /*expected_bucket_count=*/1);
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ OnSelectionChange_RequiresTransientUserActivation) {
+ const std::string histogram_name =
+ "Accessibility.ReadAnything.RendererRequestForSelection."
+ "IsFromObservedTree";
+ base::HistogramTester histogram_tester;
+
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+ content::RenderFrameHost* main_rfh =
+ browser()->GetActiveTabInterface()->GetContents()->GetPrimaryMainFrame();
+ ui::AXTreeID tree_id = main_rfh->GetAXTreeID();
+
+ // Initial state with no transient user activation
+ EXPECT_FALSE(rfh->HasTransientUserActivation());
+ handler_remote_->OnSelectionChange(tree_id, /*anchor_node_id=*/1, 0,
+ /*focus_node_id=*/2, 5);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectTotalCount(histogram_name, 0);
+
+ // Grant User Activation via simulated mouse click.
+ content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+ content::SimulateMouseClick(web_contents_.get(), 0,
+ blink::WebMouseEvent::Button::kLeft);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return rfh->HasTransientUserActivation(); }));
+ EXPECT_TRUE(rfh->HasTransientUserActivation());
+ handler_remote_->OnSelectionChange(tree_id, /*anchor_node_id=*/1, 0,
+ /*focus_node_id=*/2, 5);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(histogram_name, /*sample=*/true,
+ /*expected_bucket_count=*/1);
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ NullRenderFrameHost_ReturnsSafely) {
+ test_web_ui_->set_render_frame_host(nullptr);
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* main_rfh =
+ browser()->GetActiveTabInterface()->GetContents()->GetPrimaryMainFrame();
+ ui::AXTreeID tree_id = main_rfh->GetAXTreeID();
+
+ handler_remote_->OnCopy();
+ handler_remote_->OnLinkClicked(tree_id, 1);
+ handler_remote_->OnSelectionChange(tree_id, 1, 0, 1, 1);
+ handler_remote_->OnCollapseSelection();
+ handler_remote_.FlushForTesting();
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ OnCopy_RequiresTransientUserActivation) {
+ content::WebContents* main_contents =
+ browser()->GetActiveTabInterface()->GetContents();
+ ASSERT_TRUE(content::NavigateToURL(
+ main_contents, GURL("data:text/html,<div>Hello World</div>")));
+ main_contents->Focus();
+ main_contents->SelectAll();
+
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ content::RenderWidgetHostView* view =
+ main_contents->GetRenderWidgetHostView();
+ return view && !view->GetSelectedText().empty();
+ }));
+
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+ ui::Clipboard* clipboard = ui::Clipboard::GetForCurrentThread();
+ const ui::ClipboardSequenceNumberToken initial_seq =
+ clipboard->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste);
+
+ // Initial state with no transient user activation
+ EXPECT_FALSE(rfh->HasTransientUserActivation());
+ handler_remote_->OnCopy();
+ handler_remote_.FlushForTesting();
+ EXPECT_EQ(clipboard->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste),
+ initial_seq);
+
+ // Grant User Activation via simulated mouse click.
+ content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+ content::SimulateMouseClick(web_contents_.get(), 0,
+ blink::WebMouseEvent::Button::kLeft);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return rfh->HasTransientUserActivation(); }));
+
+ handler_remote_->OnCopy();
+ handler_remote_.FlushForTesting();
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ return clipboard->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste) !=
+ initial_seq;
+ }));
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ OnCollapseSelection_RequiresTransientUserActivation) {
+ content::WebContents* main_contents =
+ browser()->GetActiveTabInterface()->GetContents();
+ ASSERT_TRUE(content::NavigateToURL(
+ main_contents, GURL("data:text/html,<div>Selected Text</div>")));
+ main_contents->Focus();
+ main_contents->SelectAll();
+
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ content::RenderWidgetHostView* view =
+ main_contents->GetRenderWidgetHostView();
+ return view && !view->GetSelectedText().empty();
+ }));
+
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+
+ // Initial state with no transient user activation
+ EXPECT_FALSE(rfh->HasTransientUserActivation());
+ handler_remote_->OnCollapseSelection();
+ handler_remote_.FlushForTesting();
+ EXPECT_FALSE(
+ main_contents->GetRenderWidgetHostView()->GetSelectedText().empty());
+
+ // Grant User Activation via simulated mouse click.
+ content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+ content::SimulateMouseClick(web_contents_.get(), 0,
+ blink::WebMouseEvent::Button::kLeft);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return rfh->HasTransientUserActivation(); }));
+
+ handler_remote_->OnCollapseSelection();
+ handler_remote_.FlushForTesting();
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ content::RenderWidgetHostView* view =
+ main_contents->GetRenderWidgetHostView();
+ return view && view->GetSelectedText().empty();
+ }));
+}
+
} // namespace
Original Bug Report
Potential UXSS via missing user activation check in ReadAnythingUntrustedPageHandler
Flapjack, 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: The ReadAnythingUntrustedPageHandler fails to verify transient user activation for Mojo calls like OnLinkClicked. A compromised Reading Mode renderer can exploit this to silently trigger accessibility clicks on the main tab’s page. These clicks grant user activation in the target renderer, allowing unauthorized cross-origin interactions and bypassing Site Isolation.
Affected files:
chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc
Estimated timestamp from git blame: 2023-01-26
Vulnerability Analysis
The ReadAnythingUntrustedPageHandler class (chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc) implements Mojo methods that allow the untrusted Reading Mode side panel renderer (chrome-untrusted://read-anything) to interact with the primary tab’s WebContents.
Methods such as OnLinkClicked, OnSelectionChange, and OnCopy execute actions in the main tab but fail to verify that the WebUI renderer possesses transient user activation.
For example, OnLinkClicked takes a target_tree_id and target_node_id, constructs an ax::mojom::Action::kDoDefault accessibility action, and dispatches it to the main tab’s frame (PerformActionInTargetTree). When the main tab’s Blink renderer processes this action in AXObject::OnNativeClickAction, it not only performs the default click action but also explicitly grants the target document transient user activation (LocalFrame::NotifyUserActivation).
If the chrome-untrusted:// renderer is compromised, an attacker can use this lack of validation to arbitrarily click elements on cross-origin pages without any actual user interaction.
Potential Attack Steps
Note: These are suggested/potential steps based on code analysis, as our tooling agent cannot execute a live proof of concept.
- An attacker gains code execution in the untrusted Reading Mode WebUI renderer (e.g., via a renderer exploit or XSS).
- The user navigates their main tab to a sensitive cross-origin website (e.g., a banking portal or account settings page).
- The browser automatically serializes the main tab’s accessibility tree and forwards it to the compromised side panel via
page_->AccessibilityEventReceived. - The attacker parses the accessibility tree to identify a sensitive, actionable element (such as a “Transfer Funds” button) and extracts its
AXNodeIDandAXTreeID. - The attacker’s compromised renderer sends an
OnLinkClicked(target_tree_id, target_node_id)Mojo request to the browser. - The browser routes the click to the main tab’s renderer without checking if the WebUI had transient user activation.
- The main tab’s renderer simulates a trusted native click on the target node and grants the frame transient user activation, bypassing Site Isolation and achieving a UXSS-equivalent impact.
Similar unauthorized actions can be achieved using OnSelectionChange and OnCopy.
Suggested Remediation
Enforce a transient user activation check in ReadAnythingUntrustedPageHandler before acting on potentially dangerous Mojo commands.
For example, add the following check to OnLinkClicked, OnSelectionChange, and OnCopy:
if (!web_ui_->GetRenderFrameHost()->HasTransientUserActivation()) {
// Optionally log, terminate the renderer, or return early.
return;
}
Evaluated with Chrome root at commit: ae14e9176a277261f3c963589b6b045c43cec1dc
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.