CVE-2026-91735
Overview
Background
- Reading mode (Read Anything)
- a Chrome side-panel feature that distills a page’s content into a simplified view; its UI runs in an untrusted (
chrome-untrusted://) WebUI renderer that talks to the browser process over Mojo. - `ReadAnythingUntrustedPageHandler`
- the browser-process Mojo endpoint that receives action requests (link clicks, image downloads, selection changes, scrolls) from the untrusted reading-mode renderer and executes them against the observed page.
- `ui::AXTreeID`
- an identifier for an accessibility tree; the handler resolves it back to a
content::RenderFrameHostand only services requests for a tree it is actively observing (IsObservingTree). - Privileged / non-HTTP(S) page
- a page whose committed URL is not
http/https(e.g. achrome://orchrome-untrusted://WebUI orfile://page), which should not be scriptable or actionable by untrusted content.
Root Cause Analysis
The renderer-facing handlers OnLinkClicked, OnImageDataRequested, and OnSelectionChange gated their work only on IsObservingTree(target_tree_id), which confirms the target tree is one reading mode is currently observing but says nothing about the scheme or privilege level of the underlying frame. Because reading mode can distill and observe privileged surfaces (non-HTTP/HTTPS pages such as WebUI), an action request naming that observed tree passed the only guard and the handler went on to actuate a link navigation, an image-data download (DownloadImageFromAxNode), or a selection change on the privileged page. The security invariant that reading mode must remain “read only” on privileged pages — never interacting back into them — was therefore not enforced for these three action paths.
The fix adds AreActionsAllowedInTree, which resolves the tree to its RenderFrameHost and returns true only when the contents are a PDF or rfh->GetLastCommittedURL().SchemeIsHTTPOrHTTPS() is true; each of the three handlers now calls it after the observing-tree check and returns early (logging kDisallowedActionOnPageType) when the frame is not an allowed page type. This tightens the check so link clicks, image downloads, and selection changes are refused on non-HTTP/HTTPS, non-PDF frames, while the less-privileged ScrollToTargetNode is deliberately left permitted.
AreActionsAllowedInTree) that restricts link-click, image-download, and selection actions to HTTP/HTTPS or PDF frames.Attack Path
- Observe a privileged page in reading mode
With the reading-mode side panel open, cause it to distill/observe a privileged (non-HTTP/HTTPS) surface, so the handler is actively observing that page’s
AXTreeID. - Control the untrusted reading-mode renderer
An attacker who influences the untrusted (
chrome-untrusted://) reading-mode renderer crafts Mojo action requests (OnLinkClicked,OnImageDataRequested, orOnSelectionChange) naming the observed privileged tree’sAXTreeID. - Pass the only guard
Pre-patch, the request satisfies
IsObservingTree(target_tree_id), the sole gate, because the privileged tree is genuinely being observed; no scheme/page-type check exists. - Actuate an action on the privileged page
The browser process executes the request against the privileged frame — activating a link, downloading image data via
DownloadImageFromAxNode, or altering the selection — i.e. reading mode “interacts back” into a page it should treat as read-only, an incorrect-authorization condition. - Downstream escalation
Any further consequence of triggering navigations/downloads/selection on a privileged WebUI surface beyond this unauthorized interaction is not demonstrated by the patch
(inferred).
Impact Assessment
(inferred) and not established by the patch, and no exploited-in-the-wild status is stated.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.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.cctools/metrics/histograms/metadata/accessibility/enums.xmltools/metrics/histograms/metadata/accessibility/histograms.xml
Audit Directions
- Observing-tree-only authorizationSearch other Mojo handlers that gate work solely on an “am I observing this ID?” check and audit whether they also verify the frame’s scheme/privilege before actuating an effect on it.
- Reading-mode / accessibility action sinksReview every renderer-triggered action in the reading-mode and AX action-handler surfaces (navigation, downloads, selection, focus) to confirm each enforces the HTTP/HTTPS-or-PDF page-type restriction rather than assuming HTTP-only content.
- Privileged-page read-only invariantWhere a feature is documented as “read only” on
chrome:///chrome-untrusted:///file://pages, verify that every write-back path (not just the obvious ones) rejects non-HTTP/HTTPS frames, since partial enforcement (as here, whereScrollToTargetNodeis exempt) is easy to overlook.
Patch
From 8add9401a1e640c83e4b714353cb04405538ab17 Mon Sep 17 00:00:00 2001
From: Lauren Winston <lwinston@google.com>
Date: Mon, 31 Aug 2026 14:47:35 -0700
Subject: [PATCH] [Reading mode] Limit actions on non HTTP/HTTPS pages.
Prevent OnLinkClicked, OnImageDataRequested, and OnSelectionChange
from being executed when on a non-pdf privileged page.
Note that other actions such as ScrollToTargetNode are still
allowed, as they are less privileged.
Note that content will still show and select-to-distill
still work on the privileged pages, but they will be in more of
"read only" state where reading mode can't interact back with
the main page.
Also log when this happens.
Fixed: 552416113
Change-Id: I4f55c0c235bf5da58024534924b08c6d94e41787
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8310969
Reviewed-by: Kristi Saney <kristislee@google.com>
Reviewed-by: Evan Liu <evliu@google.com>
Commit-Queue: Lauren Winston <lwinston@google.com>
Cr-Commit-Position: refs/heads/main@{#1689299}
---
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 6f1bb42..1ada3c26 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
@@ -257,15 +257,13 @@
#endif
constexpr std::string_view kRendererLinkRequestHistogram =
- "Accessibility.ReadAnything.RendererRequestForLinkClick.IsFromObservedTree";
+ "Accessibility.ReadAnything.RendererRequestForLinkClick.Result";
constexpr std::string_view kRendererImageRequestHistogram =
- "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
- "IsFromObservedTree";
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result";
constexpr std::string_view kRendererScrollRequestHistogram =
- "Accessibility.ReadAnything.RendererRequestForScrollToTargetNode."
- "IsFromObservedTree";
+ "Accessibility.ReadAnything.RendererRequestForScrollToTargetNode.Result";
constexpr std::string_view kRendererSelectionRequestHistogram =
- "Accessibility.ReadAnything.RendererRequestForSelection.IsFromObservedTree";
+ "Accessibility.ReadAnything.RendererRequestForSelection.Result";
} // namespace
@@ -860,6 +858,24 @@
return pdf_rfh && rfh == pdf_rfh;
}
+bool ReadAnythingUntrustedPageHandler::AreActionsAllowedInTree(
+ const ui::AXTreeID& tree_id) const {
+ content::RenderFrameHost* rfh =
+ content::RenderFrameHost::FromAXTreeID(tree_id);
+ if (!rfh) {
+ return false;
+ }
+
+ content::WebContents* contents = GetWebContents();
+ bool are_contents_pdf =
+ chrome_pdf::features::IsOopifPdfEnabled()
+ ? !!extensions::mime_handler::MimeHandlerStreamManager::
+ FromWebContents(contents)
+ : !!pdf_observer_;
+
+ return are_contents_pdf || rfh->GetLastCommittedURL().SchemeIsHTTPOrHTTPS();
+}
+
void ReadAnythingUntrustedPageHandler::OnLineSpaceChange(
read_anything::mojom::LineSpacing line_spacing) {
profile_->GetPrefs()->SetInteger(prefs::kAccessibilityReadAnythingLineSpacing,
@@ -1075,13 +1091,26 @@
return;
}
- bool is_observing_tree = IsObservingTree(target_tree_id);
- base::UmaHistogramBoolean(kRendererLinkRequestHistogram, is_observing_tree);
- if (!is_observing_tree) {
+ if (!IsObservingTree(target_tree_id)) {
+ base::UmaHistogramEnumeration(
+ kRendererLinkRequestHistogram,
+ ReadAnythingRendererRequestResult::kNotObservedTree);
VLOG(1) << "Received link click request for tree_id " << target_tree_id
<< " which is not currently being observed";
return;
}
+
+ if (!AreActionsAllowedInTree(target_tree_id)) {
+ base::UmaHistogramEnumeration(
+ kRendererLinkRequestHistogram,
+ ReadAnythingRendererRequestResult::kDisallowedActionOnPageType);
+ VLOG(1) << "Ignoring link click on non-HTTP/HTTPS frame";
+ return;
+ }
+
+ base::UmaHistogramEnumeration(kRendererLinkRequestHistogram,
+ ReadAnythingRendererRequestResult::kAllowed);
+
if (!ui::IsValidAXNodeIDFromRenderer(target_node_id)) {
VLOG(1) << "Received link click request with invalid target_node_id "
<< target_node_id;
@@ -1098,18 +1127,32 @@
void ReadAnythingUntrustedPageHandler::OnImageDataRequested(
const ui::AXTreeID& target_tree_id,
ui::AXNodeID target_node_id) {
- bool is_observing_tree = IsObservingTree(target_tree_id);
- base::UmaHistogramBoolean(kRendererImageRequestHistogram, is_observing_tree);
- if (!is_observing_tree) {
+ if (!IsObservingTree(target_tree_id)) {
+ base::UmaHistogramEnumeration(
+ kRendererImageRequestHistogram,
+ ReadAnythingRendererRequestResult::kNotObservedTree);
VLOG(1) << "Received image data request for tree_id " << target_tree_id
<< " which is not currently being observed";
return;
}
+
+ if (!AreActionsAllowedInTree(target_tree_id)) {
+ base::UmaHistogramEnumeration(
+ kRendererImageRequestHistogram,
+ ReadAnythingRendererRequestResult::kDisallowedActionOnPageType);
+ VLOG(1) << "Ignoring image data request on non-HTTP/HTTPS frame";
+ return;
+ }
+
+ base::UmaHistogramEnumeration(kRendererImageRequestHistogram,
+ ReadAnythingRendererRequestResult::kAllowed);
+
if (!ui::IsValidAXNodeIDFromRenderer(target_node_id)) {
VLOG(1) << "Received image data request with invalid target_node_id "
<< target_node_id;
return;
}
+
main_observer_->web_contents()->DownloadImageFromAxNode(
target_tree_id, target_node_id,
/*preferred_size=*/gfx::Size(),
@@ -1153,13 +1196,17 @@
void ReadAnythingUntrustedPageHandler::ScrollToTargetNode(
const ui::AXTreeID& target_tree_id,
ui::AXNodeID target_node_id) {
- bool is_observing_tree = IsObservingTree(target_tree_id);
- base::UmaHistogramBoolean(kRendererScrollRequestHistogram, is_observing_tree);
- if (!is_observing_tree) {
+ if (!IsObservingTree(target_tree_id)) {
+ base::UmaHistogramEnumeration(
+ kRendererScrollRequestHistogram,
+ ReadAnythingRendererRequestResult::kNotObservedTree);
VLOG(1) << "Received scroll request for tree_id " << target_tree_id
<< " which is not currently being observed";
return;
}
+
+ base::UmaHistogramEnumeration(kRendererScrollRequestHistogram,
+ ReadAnythingRendererRequestResult::kAllowed);
if (!ui::IsValidAXNodeIDFromRenderer(target_node_id)) {
VLOG(1) << "Received scroll request with invalid target_node_id "
<< target_node_id;
@@ -1257,14 +1304,25 @@
return;
}
- bool is_observing_tree = IsObservingTree(target_tree_id);
- base::UmaHistogramBoolean(kRendererSelectionRequestHistogram,
- is_observing_tree);
- if (!is_observing_tree) {
+ if (!IsObservingTree(target_tree_id)) {
+ base::UmaHistogramEnumeration(
+ kRendererSelectionRequestHistogram,
+ ReadAnythingRendererRequestResult::kNotObservedTree);
VLOG(1) << "Received selection request for tree_id " << target_tree_id
<< " which is not currently being observed";
return;
}
+
+ if (!AreActionsAllowedInTree(target_tree_id)) {
+ base::UmaHistogramEnumeration(
+ kRendererSelectionRequestHistogram,
+ ReadAnythingRendererRequestResult::kDisallowedActionOnPageType);
+ VLOG(1) << "Ignoring selection change on non-HTTP/HTTPS frame";
+ return;
+ }
+
+ base::UmaHistogramEnumeration(kRendererSelectionRequestHistogram,
+ ReadAnythingRendererRequestResult::kAllowed);
if (!ui::IsValidAXNodeIDFromRenderer(anchor_node_id)) {
VLOG(1) << "Received selection request with invalid anchor_node_id "
<< anchor_node_id;
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 62b2da2..7b37f99e 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
@@ -87,6 +87,18 @@
// LINT.ThenChange(/tools/metrics/histograms/metadata/accessibility/enums.xml:ReadAnythingDistillationScheme)
+// LINT.IfChange(ReadAnythingRendererRequestResult)
+// These values are persisted to logs. Entries should not be renumbered and
+// numeric values should never be reused.
+enum class ReadAnythingRendererRequestResult {
+ kAllowed = 0,
+ kNotObservedTree = 1,
+ kDisallowedActionOnPageType = 2,
+ kMaxValue = kDisallowedActionOnPageType,
+};
+
+// LINT.ThenChange(/tools/metrics/histograms/metadata/accessibility/enums.xml:ReadAnythingRendererRequestResult)
+
enum class ListenToThisPagePlaybackMetricState {
kInactive = 0,
kWaitingForAudioStart,
@@ -330,6 +342,11 @@
// observed tree. If it's not, it may be a malicious request.
bool IsObservingTree(const ui::AXTreeID& tree_id) const;
+ // Used to verify if an incoming action request (e.g. clicking a link or
+ // downloading an image) is allowed on the current tree. Actions are allowed
+ // if the page is HTTP/HTTPS or a PDF.
+ bool AreActionsAllowedInTree(const ui::AXTreeID& tree_id) const;
+
// ui::AXActionHandlerObserver:
void TreeRemoved(ui::AXTreeID ax_tree_id) override;
diff --git a/tools/metrics/histograms/metadata/accessibility/enums.xml b/tools/metrics/histograms/metadata/accessibility/enums.xml
index 1c832a0..9fe3f0a 100644
--- a/tools/metrics/histograms/metadata/accessibility/enums.xml
+++ b/tools/metrics/histograms/metadata/accessibility/enums.xml
@@ -2403,6 +2403,20 @@
<!-- LINT.ThenChange(//chrome/browser/resources/side_panel/read_anything/shared/metrics_browser_proxy.ts:ReadAnythingVoiceType) -->
+<!-- LINT.IfChange(ReadAnythingRendererRequestResult) -->
+
+<enum name="ReadAnythingRendererRequestResult">
+ <summary>
+ The result of an action request from the Read Anything untrusted WebUI
+ renderer to the browser process.
+ </summary>
+ <int value="0" label="Allowed"/>
+ <int value="1" label="Failed: Not from observed tree"/>
+ <int value="2" label="Failed: Disallowed on page type"/>
+</enum>
+
+<!-- LINT.ThenChange(//chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h:ReadAnythingRendererRequestResult) -->
+
<!-- LINT.IfChange(ReadAnythingScrollEvent) -->
<enum name="ReadAnythingScrollEvent">
diff --git a/tools/metrics/histograms/metadata/accessibility/histograms.xml b/tools/metrics/histograms/metadata/accessibility/histograms.xml
index b4560f6..e8331ecd 100644
--- a/tools/metrics/histograms/metadata/accessibility/histograms.xml
+++ b/tools/metrics/histograms/metadata/accessibility/histograms.xml
@@ -3627,16 +3627,15 @@
</token>
</histogram>
-<histogram
- name="Accessibility.ReadAnything.RendererRequestFor{Action}.IsFromObservedTree"
- enum="Boolean" expires_after="2027-02-07">
+<histogram name="Accessibility.ReadAnything.RendererRequestFor{Action}.Result"
+ enum="ReadAnythingRendererRequestResult" expires_after="2027-02-07">
+ <owner>lwinston@google.com</owner>
<owner>kristislee@google.com</owner>
<owner>komo-eng@google.com</owner>
<summary>
- Records whether action requests received by the
- ReadAnythingUntrustedPageHandler (e.g. link click or image download
- requests) are from the currently observed tree. If not, this may be a
- malicious request.
+ Records the validation result of action requests ({Action}) received by the
+ ReadAnythingUntrustedPageHandler. If not allowed, this may be a malicious
+ request.
</summary>
<token key="Action">
<variant name="ImageDataDownload" summary="requested to download an image"/>
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 761e053..10dd524 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
@@ -39,6 +39,7 @@
#include "chrome/common/read_anything/read_anything.mojom-shared.h"
#include "chrome/common/read_anything/read_anything.mojom.h"
#include "chrome/common/webui_url_constants.h"
+#include "chrome/test/base/chrome_test_path_utils.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/language_detection/core/constants.h"
@@ -487,6 +488,36 @@
handler_->OnTranslateDriverDestroyed(driver);
}
+ void SetUpHandler() {
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+ }
+
+ void GrantUserActivation(content::RenderFrameHost* rfh) {
+ handler_remote_.FlushForTesting();
+ page_.receiver_.FlushForTesting();
+ 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());
+ }
+
+ content::RenderFrameHost* LoadPdf(const GURL& url) {
+ content::WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+ if (!ui_test_utils::NavigateToURL(browser(), url)) {
+ return nullptr;
+ }
+ if (!pdf_extension_test_util::EnsurePDFHasLoaded(web_contents)) {
+ return nullptr;
+ }
+ return pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents);
+ }
+
protected:
#if BUILDFLAG(IS_CHROMEOS)
raw_ptr<MockChromeOsExtensionWrapper> extension_wrapper_ptr_ = nullptr;
@@ -1038,6 +1069,14 @@
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
OnImageDataRequested_IgnoresBadTreeId) {
base::HistogramTester histogram_tester;
+
+ // In order to test the bad tree id, first ensure that reading mode is not
+ // on a privileged page.
+ ASSERT_TRUE(embedded_test_server()->Start());
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(),
+ embedded_test_server()->GetURL("/downloads/large_image.html")));
+
handler_ = CreateHandler();
auto tree_id = ui::AXTreeID::CreateNewAXTreeID();
ui::AXNodeID node_id = 1;
@@ -1045,9 +1084,8 @@
OnImageDataRequested(tree_id, node_id);
histogram_tester.ExpectUniqueSample(
- "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
- "IsFromObservedTree",
- false, 1);
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+ ReadAnythingRendererRequestResult::kNotObservedTree, 1);
}
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -1065,9 +1103,8 @@
OnImageDataRequested(tree_id, 1);
histogram_tester.ExpectUniqueSample(
- "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
- "IsFromObservedTree",
- true, 1);
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+ ReadAnythingRendererRequestResult::kAllowed, 1);
}
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -2453,14 +2490,13 @@
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
OnLinkClicked_RequiresTransientUserActivation) {
const std::string histogram_name =
- "Accessibility.ReadAnything.RendererRequestForLinkClick."
- "IsFromObservedTree";
+ "Accessibility.ReadAnything.RendererRequestForLinkClick.Result";
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();
+ ASSERT_TRUE(embedded_test_server()->Start());
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), embedded_test_server()->GetURL("/simple.html")));
+ SetUpHandler();
content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
content::RenderFrameHost* main_rfh =
@@ -2475,29 +2511,24 @@
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());
+ GrantUserActivation(rfh);
handler_remote_->OnLinkClicked(tree_id, node_id);
handler_remote_.FlushForTesting();
... (file diff truncated)
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 761e053..10dd524 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
@@ -39,6 +39,7 @@
#include "chrome/common/read_anything/read_anything.mojom-shared.h"
#include "chrome/common/read_anything/read_anything.mojom.h"
#include "chrome/common/webui_url_constants.h"
+#include "chrome/test/base/chrome_test_path_utils.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/language_detection/core/constants.h"
@@ -487,6 +488,36 @@
handler_->OnTranslateDriverDestroyed(driver);
}
+ void SetUpHandler() {
+ ASSERT_TRUE(
+ content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+ test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+ handler_ = CreateHandler();
+ }
+
+ void GrantUserActivation(content::RenderFrameHost* rfh) {
+ handler_remote_.FlushForTesting();
+ page_.receiver_.FlushForTesting();
+ 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());
+ }
+
+ content::RenderFrameHost* LoadPdf(const GURL& url) {
+ content::WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+ if (!ui_test_utils::NavigateToURL(browser(), url)) {
+ return nullptr;
+ }
+ if (!pdf_extension_test_util::EnsurePDFHasLoaded(web_contents)) {
+ return nullptr;
+ }
+ return pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents);
+ }
+
protected:
#if BUILDFLAG(IS_CHROMEOS)
raw_ptr<MockChromeOsExtensionWrapper> extension_wrapper_ptr_ = nullptr;
@@ -1038,6 +1069,14 @@
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
OnImageDataRequested_IgnoresBadTreeId) {
base::HistogramTester histogram_tester;
+
+ // In order to test the bad tree id, first ensure that reading mode is not
+ // on a privileged page.
+ ASSERT_TRUE(embedded_test_server()->Start());
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(),
+ embedded_test_server()->GetURL("/downloads/large_image.html")));
+
handler_ = CreateHandler();
auto tree_id = ui::AXTreeID::CreateNewAXTreeID();
ui::AXNodeID node_id = 1;
@@ -1045,9 +1084,8 @@
OnImageDataRequested(tree_id, node_id);
histogram_tester.ExpectUniqueSample(
- "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
- "IsFromObservedTree",
- false, 1);
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+ ReadAnythingRendererRequestResult::kNotObservedTree, 1);
}
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -1065,9 +1103,8 @@
OnImageDataRequested(tree_id, 1);
histogram_tester.ExpectUniqueSample(
- "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
- "IsFromObservedTree",
- true, 1);
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+ ReadAnythingRendererRequestResult::kAllowed, 1);
}
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -2453,14 +2490,13 @@
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
OnLinkClicked_RequiresTransientUserActivation) {
const std::string histogram_name =
- "Accessibility.ReadAnything.RendererRequestForLinkClick."
- "IsFromObservedTree";
+ "Accessibility.ReadAnything.RendererRequestForLinkClick.Result";
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();
+ ASSERT_TRUE(embedded_test_server()->Start());
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), embedded_test_server()->GetURL("/simple.html")));
+ SetUpHandler();
content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
content::RenderFrameHost* main_rfh =
@@ -2475,29 +2511,24 @@
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());
+ GrantUserActivation(rfh);
handler_remote_->OnLinkClicked(tree_id, node_id);
handler_remote_.FlushForTesting();
- histogram_tester.ExpectUniqueSample(histogram_name, /*sample=*/true,
- /*expected_bucket_count=*/1);
+ histogram_tester.ExpectUniqueSample(
+ histogram_name, ReadAnythingRendererRequestResult::kAllowed,
+ /*expected_bucket_count=*/1);
}
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
OnSelectionChange_RequiresTransientUserActivation) {
const std::string histogram_name =
- "Accessibility.ReadAnything.RendererRequestForSelection."
- "IsFromObservedTree";
+ "Accessibility.ReadAnything.RendererRequestForSelection.Result";
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();
+ ASSERT_TRUE(embedded_test_server()->Start());
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), embedded_test_server()->GetURL("/simple.html")));
+ SetUpHandler();
content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
content::RenderFrameHost* main_rfh =
@@ -2512,17 +2543,13 @@
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());
+ GrantUserActivation(rfh);
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);
+ histogram_tester.ExpectUniqueSample(
+ histogram_name, ReadAnythingRendererRequestResult::kAllowed,
+ /*expected_bucket_count=*/1);
}
IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -2556,10 +2583,7 @@
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();
+ SetUpHandler();
content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
ui::Clipboard* clipboard = ui::Clipboard::GetForCurrentThread();
@@ -2574,11 +2598,7 @@
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(); }));
+ GrantUserActivation(rfh);
handler_remote_->OnCopy();
handler_remote_.FlushForTesting();
@@ -2603,10 +2623,7 @@
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();
+ SetUpHandler();
content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
@@ -2618,11 +2635,7 @@
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(); }));
+ GrantUserActivation(rfh);
handler_remote_->OnCollapseSelection();
handler_remote_.FlushForTesting();
@@ -2633,4 +2646,152 @@
}));
}
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ PrivilegedScheme_DisallowsActions) {
+ base::HistogramTester histogram_tester;
+
+ ASSERT_TRUE(
+ ui_test_utils::NavigateToURL(browser(), GURL("chrome://version/")));
+ SetUpHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+ content::RenderFrameHost* main_rfh =
+ browser()->GetActiveTabInterface()->GetContents()->GetPrimaryMainFrame();
+ ui::AXTreeID tree_id = main_rfh->GetAXTreeID();
+
+ GrantUserActivation(rfh);
+
+ // Link clicks are dropped without error.
+ handler_remote_->OnLinkClicked(tree_id, /*target_node_id=*/1);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(
+ "Accessibility.ReadAnything.RendererRequestForLinkClick.Result",
+ ReadAnythingRendererRequestResult::kDisallowedActionOnPageType,
+ /*expected_bucket_count=*/1);
+
+ // Image data requests are dropped without error.
+ handler_remote_->OnImageDataRequested(tree_id, /*target_node_id=*/1);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+ ReadAnythingRendererRequestResult::kDisallowedActionOnPageType,
+ /*expected_bucket_count=*/1);
+
+ // Scroll requests succeed on privileged schemes.
+ handler_remote_->ScrollToTargetNode(tree_id, /*target_node_id=*/1);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(
+ "Accessibility.ReadAnything.RendererRequestForScrollToTargetNode.Result",
+ ReadAnythingRendererRequestResult::kAllowed,
+ /*expected_bucket_count=*/1);
+
+ // Selection change requests are dropped without error.
+ handler_remote_->OnSelectionChange(tree_id, /*anchor_node_id=*/1, 0,
+ /*focus_node_id=*/2, 5);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(
+ "Accessibility.ReadAnything.RendererRequestForSelection.Result",
+ ReadAnythingRendererRequestResult::kDisallowedActionOnPageType,
+ /*expected_bucket_count=*/1);
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+ Pdf_AllowsActions) {
+ base::HistogramTester histogram_tester;
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+ content::RenderFrameHost* pdf_rfh =
+ LoadPdf(embedded_test_server()->GetURL("/pdf/test.pdf"));
+ ASSERT_TRUE(pdf_rfh);
+ SetUpHandler();
+
+ content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+ GrantUserActivation(rfh);
+
+ // Link clicks succeed for PDF tree.
+ handler_remote_->OnLinkClicked(pdf_rfh->GetAXTreeID(), /*target_node_id=*/1);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(
+ "Accessibility.ReadAnything.RendererRequestForLinkClick.Result",
+ ReadAnythingRendererRequestResult::kAllowed,
+ /*expected_bucket_count=*/1);
+
+ // Image data requests succeed for PDF tree.
+ handler_remote_->OnImageDataRequested(pdf_rfh->GetAXTreeID(),
+ /*target_node_id=*/1);
+ handler_remote_.FlushForTesting();
+ histogram_tester.ExpectUniqueSample(
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+ ReadAnythingRendererRequestResult::kAllowed,
... (truncated)
Original Bug Report
Potential Sandbox Escape via Reading Mode WebUI Accessibility Actions
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: A compromised Reading Mode WebUI (chrome-untrusted://) can receive the full accessibility tree of privileged chrome:// pages when they are focused. The untrusted WebUI can then send an OnLinkClicked IPC message to programmatically trigger clicks on sensitive elements within the chrome:// page, leading to a potential sandbox escape.
Affected files:
chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.ccchrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h
Estimated timestamp from git blame: 2023-01-26
Summary
A potential high-severity sandbox escape exists in the Reading Mode (Read Anything) side panel implementation. If an attacker compromises the untrusted Reading Mode renderer (chrome-untrusted://read-anything-side-panel.top-chrome/), they can abuse the ReadAnythingUntrustedPageHandler Mojo interface to interact with highly privileged chrome:// pages (such as chrome://settings). By programmatically clicking sensitive elements on these pages, an attacker could disable security protections, alter proxy settings, or clear user data without user interaction.
Technical Analysis
The Reading Mode WebUI communicates with the browser process via ReadAnythingUntrustedPageHandler. To function, it monitors the user’s active tab using a ReadAnythingWebContentsObserver, which enables kReadAnythingAXMode (ui::kAXModeWebContentsOnly | ui::AXMode::kHTML) on the active WebContents. This forces the active tab’s renderer to serialize and transmit its full accessibility tree to the browser.
When a user navigates the active tab to a privileged page like chrome://settings, the following sequence allows a compromised untrusted renderer to exploit the privileged page:
- Leaking the Privileged Tree ID:
ReadAnythingUntrustedPageHandler::OnActiveAXTreeIDChangedsends theAXTreeIDof the activechrome://settingspage to the untrusted renderer viapage_->OnActiveAXTreeIDChanged(). While there is a scheme check (!url.SchemeIsHTTPOrHTTPS()) inRequestDomDistillerDistillation(), this check occurs after the tree ID is transmitted and only prevents text distillation, failing to protect the accessibility tree ID. - Unfiltered Event Forwarding: The browser receives accessibility updates from the
chrome://settingspage and invokesReadAnythingUntrustedPageHandler::AccessibilityEventReceived. This function unconditionally forwards the entireui::AXUpdatesAndEventspayload (containing the serializedchrome://settingsDOM) to the untrusted renderer. - Initiating an Unauthorized Click: The attacker parses the leaked accessibility tree to find the
AXNodeIDof a sensitive control (e.g., a toggle to disable Safe Browsing) and invokes the Mojo methodOnLinkClicked(target_tree_id, target_node_id). - Insufficient Action Validation: In
ReadAnythingUntrustedPageHandler::OnLinkClicked, the browser verifies the request by callingIsObservingTree(target_tree_id). This function merely checks if the requested tree ID belongs to the currently observedWebContents. Since the attacker provided the legitimate tree ID of the activechrome://settingstab, this check passes. No scheme validation is performed. - Simulating the Click: The browser constructs a
ui::AXActionDataobject with the action set toax::mojom::Action::kDoDefaultand routes it to thechrome://settingsrenderer. The privileged renderer simulates a full click sequence (pointer down, mouse down, click, etc.) on the target element, successfully executing the sensitive action and bypassing the sandbox boundary.
Potential Exploitation Steps
Note: These steps are suggested based on static code analysis; our tooling agent cannot execute a live proof of concept.
- An attacker gains arbitrary code execution in the
chrome-untrusted://read-anything-side-panel.top-chrome/renderer process (e.g., via a V8 vulnerability or UXSS). - The attacker’s script listens for
OnActiveAXTreeIDChangedandAccessibilityEventReceivedMojo messages. - The attacker waits for the user to navigate the active tab to
chrome://settings/. - Upon receiving the
AccessibilityEventReceivedpayload, the attacker parses the serialized accessibility tree to locate theAXNodeIDfor the “Safe Browsing” toggle switch. - The attacker invokes
UntrustedPageHandler::OnLinkClicked(target_tree_id, safe_browsing_node_id). - The browser translates this into a synthetic click in the
chrome://settingsrenderer, turning off Safe Browsing without any actual user interaction.
Suggested Fix
- Scheme Validation for Observation:
ReadAnythingUntrustedPageHandlerandReadAnythingWebContentsObservermust strictly validate the URL scheme of the active tab. They should refuse to enable accessibility observation, serialize accessibility trees, or sendOnActiveAXTreeIDChangedfor pages with privileged schemes (e.g.,chrome://,file://,chrome-extension://). - Strict Action Filtering: Add explicit validation inside
OnLinkClicked,OnSelectionChange, and other action handlers to ensure thetarget_tree_idresolves to an unprivileged web page (HTTP/HTTPS) before forwarding any actions to the renderer.
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.