CVE-2026-78999
Overview
Background
- Reading Mode (`read_anything`)
- a browser feature that shows a distilled, simplified copy of the current page’s content in a side panel (
ReadAnythingSidePanelWebView) or an immersive overlay (ReadAnythingImmersiveWebView), each hosted in its ownWebContents. - `WebContentsDelegate::OpenURLFromTab`
- the browser-side hook called when a hosted
WebContentsasks to open a URL (for example when a link is clicked). These Reading Mode views override it to forward the navigation to the main browser tab or controller. - `OpenURLParams`
- the navigation request (
params.url, disposition, transition) handed toOpenURLFromTab. Its URL comes from the content running inside the Reading ModeWebContents. - `GURL::SchemeIsHTTPOrHTTPS`
- an allowlist check that is true only for
http:andhttps:URLs.
Root Cause Analysis
Both Reading Mode web views implement OpenURLFromTab, which forwards a navigation requested by the Reading Mode contents to the main browser (the immersive view through its controller, the side panel view through ReadAnythingSidePanelController). Before this patch, the only filter was a blocklist that returned nullptr for content::kChromeUIScheme, url::kFileScheme, content::kChromeUIUntrustedScheme and url::kJavaScriptScheme. The commit says the earlier fix (crrev.com/c/7851361) “didn’t limit enough”: every scheme not on the list was still forwarded. The regression test lists such schemes: data:, filesystem:, blob:, about:, devtools:// and chrome-extension://. Reading Mode only shows links taken from distilled web content, so forwarding non-web schemes broke the invariant that links from a web page get no more navigation ability than the page itself. The test also shows these requests bypassed the underlying tab’s PopupBlockerTabHelper path.
The fix replaces the blocklist with an allowlist, if (!params.url.SchemeIsHTTPOrHTTPS()) return nullptr;, so every non-web scheme is dropped before it reaches the main browser.
http/https, which is all distilled web content should ever need.Attack Path
- Attacker page with crafted links
The attacker serves a page whose content includes links with non-web targets (for example
devtools://,chrome-extension://,data:orfilesystem:URLs, as in the regression test) that survive into the Reading Mode view (inferred; the patch does not show how distillation handles link schemes). - User opens Reading Mode The user opens Reading Mode for that page in the side panel or the immersive overlay. This is a user action, not something the page can force.
- Link activation forwarded by the browser
Clicking such a link in Reading Mode makes the hosted
WebContentscallOpenURLFromTab. Before the patch, any scheme other thanchrome:,chrome-untrusted:,file:andjavascript:was forwarded to the main browser. - Navigation outside normal web restrictions The main browser opens the target, for example in a new foreground tab, by a path that skips the source tab’s popup blocker. A page could therefore reach URLs it could not normally open itself, such as DevTools or extension pages (inferred; the test shows only that these requests were forwarded and that the popup blocker never saw them).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forchrome/browser/ui/read_anything/read_anything_controller_browsertest.cc |
modified | |
ASSERT_TRUEchrome/browser/ui/read_anything/read_anything_controller_browsertest.cc |
modified |
Files Changed
chrome/browser/ui/read_anything/read_anything_immersive_web_view.ccchrome/browser/ui/read_anything/read_anything_side_panel_web_view.ccchrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
Audit Directions
- Scheme blocklists in navigation forwardersSearch
WebContentsDelegate::OpenURLFromTab,AddNewContents, and similar overrides in side panels, WebUI bubbles and overlays forSchemeIs(...)blocklists, and replace them with allowlists suited to what the hosted content legitimately needs. - Untrusted content in trusted surfacesFeatures that show web-derived content inside browser-owned
WebContents(Reading Mode, Lens, side panel search, previews) should be checked to make sure forwarded navigations keep the initiator’s restrictions, including popup blocking, user activation and the initiator origin. - Incomplete prior fixesThis is a follow-up to an earlier partial fix. When reviewing a fix that enumerates bad inputs, list every scheme
GURLcan parse (blob,filesystem,data,about,devtools,chrome-extension,view-source, and others) and confirm each one is handled.
Patch
From 78ee3d3614403afa6a536b34e6352d04e6ea3976 Mon Sep 17 00:00:00 2001
From: Lauren Winston <lwinston@google.com>
Date: Wed, 15 Jul 2026 20:21:29 -0700
Subject: [PATCH] [Reading mode] Limit opening URLs to just HTTP/HTTPS.
The previous fix in crrev.com/c/7851361 didn't limit enough.
Bug: 497957278
Change-Id: I64614003c01d94504c3fe7305ad46caced6c94d9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8104740
Reviewed-by: Kristi Saney <kristislee@google.com>
Commit-Queue: Lauren Winston <lwinston@google.com>
Cr-Commit-Position: refs/heads/main@{#1663023}
---
diff --git a/chrome/browser/ui/read_anything/read_anything_immersive_web_view.cc b/chrome/browser/ui/read_anything/read_anything_immersive_web_view.cc
index 8e9e227..6dd8967e 100644
--- a/chrome/browser/ui/read_anything/read_anything_immersive_web_view.cc
+++ b/chrome/browser/ui/read_anything/read_anything_immersive_web_view.cc
@@ -19,11 +19,9 @@
#include "components/tabs/public/tab_interface.h"
#include "content/public/browser/context_menu_params.h"
#include "content/public/browser/web_contents.h"
-#include "content/public/common/url_constants.h"
#include "third_party/blink/public/common/input/web_input_event.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/events/keycodes/keyboard_codes.h"
-#include "url/url_constants.h"
ReadAnythingImmersiveWebView::ReadAnythingImmersiveWebView(
base::OnceClosure on_show_ui_callback,
@@ -65,11 +63,9 @@
const content::OpenURLParams& params,
base::OnceCallback<void(content::NavigationHandle&)>
navigation_handle_callback) {
- // Block navigation to unsupported URL schemes.
- if (params.url.SchemeIs(content::kChromeUIScheme) ||
- params.url.SchemeIs(url::kFileScheme) ||
- params.url.SchemeIs(content::kChromeUIUntrustedScheme) ||
- params.url.SchemeIs(url::kJavaScriptScheme)) {
+ // Reading Mode only renders links from distilled web content, so restrict
+ // forwarded navigations to web schemes.
+ if (!params.url.SchemeIsHTTPOrHTTPS()) {
return nullptr;
}
auto* controller =
diff --git a/chrome/browser/ui/read_anything/read_anything_side_panel_web_view.cc b/chrome/browser/ui/read_anything/read_anything_side_panel_web_view.cc
index 55e5073..f416788 100644
--- a/chrome/browser/ui/read_anything/read_anything_side_panel_web_view.cc
+++ b/chrome/browser/ui/read_anything/read_anything_side_panel_web_view.cc
@@ -18,11 +18,9 @@
#include "content/public/browser/context_menu_params.h"
#include "content/public/browser/global_routing_id.h"
#include "content/public/browser/web_contents_delegate.h"
-#include "content/public/common/url_constants.h"
#include "ui/accessibility/accessibility_features.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/events/keycodes/keyboard_codes.h"
-#include "url/url_constants.h"
using SidePanelWebUIViewT_ReadAnythingUntrustedUI =
SidePanelWebUIViewT<ReadAnythingUntrustedUI>;
@@ -69,11 +67,9 @@
const content::OpenURLParams& params,
base::OnceCallback<void(content::NavigationHandle&)>
navigation_handle_callback) {
- // Block navigation to unsupported URL schemes.
- if (params.url.SchemeIs(content::kChromeUIScheme) ||
- params.url.SchemeIs(url::kFileScheme) ||
- params.url.SchemeIs(content::kChromeUIUntrustedScheme) ||
- params.url.SchemeIs(url::kJavaScriptScheme)) {
+ // Reading Mode only renders links from distilled web content, so restrict
+ // forwarded navigations to web schemes.
+ if (!params.url.SchemeIsHTTPOrHTTPS()) {
return nullptr;
}
ReadAnythingSidePanelController* controller =
diff --git a/chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc b/chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
index b87ef752..0f3e2dd 100644
--- a/chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
+++ b/chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
@@ -2090,6 +2090,85 @@
EXPECT_EQ(initial_tab_count, browser()->tab_strip_model()->count());
}
+IN_PROC_BROWSER_TEST_F(ReadAnythingControllerBrowserTest,
+ OpenURLFromTab_OnlyAllowsWebSchemes) {
+ // Links rendered in Reading Mode come from distilled web content, so only
+ // http and https targets are expected to reach the main browser.
+ GURL url(embedded_test_server()->GetURL("/simple.html"));
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url));
+
+ tabs::TabInterface* tab = browser()->tab_strip_model()->GetActiveTab();
+ ASSERT_TRUE(tab);
+ auto* controller = ReadAnythingController::From(tab);
+ ASSERT_TRUE(controller);
+
+ // 1. Check Immersive mode.
+ controller->ShowImmersiveUI(ReadAnythingOpenTrigger::kOmniboxChip);
+ AwaitAndAssertOverlayVisibility(/*visible=*/true);
+
+ content::WebContents* immersive_contents = GetImmersiveWebContents();
+ ASSERT_TRUE(immersive_contents);
+ content::WebContentsDelegate* immersive_delegate =
+ immersive_contents->GetDelegate();
+ ASSERT_TRUE(immersive_delegate);
+
+ int initial_tab_count = browser()->tab_strip_model()->count();
+ auto* popup_blocker = blocked_content::PopupBlockerTabHelper::FromWebContents(
+ tab->GetContents());
+ ASSERT_TRUE(popup_blocker);
+
+ const GURL non_web_urls[] = {
+ GURL("data:text/html,<p>hi</p>"),
+ GURL("filesystem:http://example.com/temporary/a"),
+ GURL("blob:null/abc"),
+ GURL("about:blank"),
+ GURL("devtools://devtools/bundled/inspector.html"),
+ GURL("chrome-extension://abc/popup.html"),
+ };
+ for (const GURL& target : non_web_urls) {
+ content::OpenURLParams params(target, content::Referrer(),
+ WindowOpenDisposition::NEW_FOREGROUND_TAB,
+ ui::PAGE_TRANSITION_LINK, false);
+ EXPECT_EQ(nullptr, immersive_delegate->OpenURLFromTab(
+ immersive_contents, params, base::DoNothing()))
+ << target;
+ EXPECT_EQ(initial_tab_count, browser()->tab_strip_model()->count())
+ << target;
+ }
+ // The requests are dropped by the host before reaching the main browser, so
+ // the popup blocker on the underlying tab never sees them.
+ EXPECT_EQ(0u, popup_blocker->GetBlockedPopupsCount());
+
+ controller->CloseImmersiveUI(ReadAnythingCloseReason::kClosedByUser);
+ AssertOverlayVisibility(/*visible=*/false);
+
+ // 2. Check Side Panel mode.
+ controller->ShowSidePanelUI(SidePanelOpenTrigger::kAppMenu);
+ auto* side_panel_ui = browser()->GetFeatures().side_panel_ui();
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ return side_panel_ui->IsSidePanelEntryShowing(
+ SidePanelEntryKey(SidePanelEntryId::kReadAnything));
+ }));
+
+ content::WebContents* side_panel_contents = GetSidePanelWebContents();
+ ASSERT_TRUE(side_panel_contents);
+ content::WebContentsDelegate* side_panel_delegate =
+ side_panel_contents->GetDelegate();
+ ASSERT_TRUE(side_panel_delegate);
+
+ for (const GURL& target : non_web_urls) {
+ content::OpenURLParams params(target, content::Referrer(),
+ WindowOpenDisposition::NEW_FOREGROUND_TAB,
+ ui::PAGE_TRANSITION_LINK, false);
+ EXPECT_EQ(nullptr, side_panel_delegate->OpenURLFromTab(
+ side_panel_contents, params, base::DoNothing()))
+ << target;
+ EXPECT_EQ(initial_tab_count, browser()->tab_strip_model()->count())
+ << target;
+ }
+ EXPECT_EQ(0u, popup_blocker->GetBlockedPopupsCount());
+}
+
IN_PROC_BROWSER_TEST_F(
ReadAnythingControllerBrowserTest,
HandleKeyboardEvent_WhenFullscreenInImmersiveMode_EscapeClosesFullscreen) {
Regression Test / PoC
diff --git a/chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc b/chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
index b87ef752..0f3e2dd 100644
--- a/chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
+++ b/chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
@@ -2090,6 +2090,85 @@
EXPECT_EQ(initial_tab_count, browser()->tab_strip_model()->count());
}
+IN_PROC_BROWSER_TEST_F(ReadAnythingControllerBrowserTest,
+ OpenURLFromTab_OnlyAllowsWebSchemes) {
+ // Links rendered in Reading Mode come from distilled web content, so only
+ // http and https targets are expected to reach the main browser.
+ GURL url(embedded_test_server()->GetURL("/simple.html"));
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url));
+
+ tabs::TabInterface* tab = browser()->tab_strip_model()->GetActiveTab();
+ ASSERT_TRUE(tab);
+ auto* controller = ReadAnythingController::From(tab);
+ ASSERT_TRUE(controller);
+
+ // 1. Check Immersive mode.
+ controller->ShowImmersiveUI(ReadAnythingOpenTrigger::kOmniboxChip);
+ AwaitAndAssertOverlayVisibility(/*visible=*/true);
+
+ content::WebContents* immersive_contents = GetImmersiveWebContents();
+ ASSERT_TRUE(immersive_contents);
+ content::WebContentsDelegate* immersive_delegate =
+ immersive_contents->GetDelegate();
+ ASSERT_TRUE(immersive_delegate);
+
+ int initial_tab_count = browser()->tab_strip_model()->count();
+ auto* popup_blocker = blocked_content::PopupBlockerTabHelper::FromWebContents(
+ tab->GetContents());
+ ASSERT_TRUE(popup_blocker);
+
+ const GURL non_web_urls[] = {
+ GURL("data:text/html,<p>hi</p>"),
+ GURL("filesystem:http://example.com/temporary/a"),
+ GURL("blob:null/abc"),
+ GURL("about:blank"),
+ GURL("devtools://devtools/bundled/inspector.html"),
+ GURL("chrome-extension://abc/popup.html"),
+ };
+ for (const GURL& target : non_web_urls) {
+ content::OpenURLParams params(target, content::Referrer(),
+ WindowOpenDisposition::NEW_FOREGROUND_TAB,
+ ui::PAGE_TRANSITION_LINK, false);
+ EXPECT_EQ(nullptr, immersive_delegate->OpenURLFromTab(
+ immersive_contents, params, base::DoNothing()))
+ << target;
+ EXPECT_EQ(initial_tab_count, browser()->tab_strip_model()->count())
+ << target;
+ }
+ // The requests are dropped by the host before reaching the main browser, so
+ // the popup blocker on the underlying tab never sees them.
+ EXPECT_EQ(0u, popup_blocker->GetBlockedPopupsCount());
+
+ controller->CloseImmersiveUI(ReadAnythingCloseReason::kClosedByUser);
+ AssertOverlayVisibility(/*visible=*/false);
+
+ // 2. Check Side Panel mode.
+ controller->ShowSidePanelUI(SidePanelOpenTrigger::kAppMenu);
+ auto* side_panel_ui = browser()->GetFeatures().side_panel_ui();
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ return side_panel_ui->IsSidePanelEntryShowing(
+ SidePanelEntryKey(SidePanelEntryId::kReadAnything));
+ }));
+
+ content::WebContents* side_panel_contents = GetSidePanelWebContents();
+ ASSERT_TRUE(side_panel_contents);
+ content::WebContentsDelegate* side_panel_delegate =
+ side_panel_contents->GetDelegate();
+ ASSERT_TRUE(side_panel_delegate);
+
+ for (const GURL& target : non_web_urls) {
+ content::OpenURLParams params(target, content::Referrer(),
+ WindowOpenDisposition::NEW_FOREGROUND_TAB,
+ ui::PAGE_TRANSITION_LINK, false);
+ EXPECT_EQ(nullptr, side_panel_delegate->OpenURLFromTab(
+ side_panel_contents, params, base::DoNothing()))
+ << target;
+ EXPECT_EQ(initial_tab_count, browser()->tab_strip_model()->count())
+ << target;
+ }
+ EXPECT_EQ(0u, popup_blocker->GetBlockedPopupsCount());
+}
+
IN_PROC_BROWSER_TEST_F(
ReadAnythingControllerBrowserTest,
HandleKeyboardEvent_WhenFullscreenInImmersiveMode_EscapeClosesFullscreen) {
Original Bug Report
Privilege escalation: chrome-untrusted:// can open chrome:// URLs via Read Anything
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised chrome-untrusted:// renderer, such as the Read Anything side panel, can potentially navigate the main browser window to sensitive chrome:// and file:// URLs. This occurs because untrusted WebUIs incorrectly inherit privileged requestable schemes, and their navigation requests are subsequently laundered as browser-initiated.
Affected files:
chrome/browser/ui/read_anything/read_anything_side_panel_web_view.cccontent/browser/renderer_host/navigator.cccontent/browser/webui/web_ui_impl.cccontent/browser/renderer_host/render_frame_host_impl.ccui/webui/untrusted_web_ui_controller.cc
Estimated timestamp from git blame: 2024-12-10
Description
There is a potential security bypass in the chrome-untrusted:// security model. A compromised renderer hosting a chrome-untrusted:// page (such as the Read Anything side panel) can force the main browser window to navigate to highly privileged URLs, such as chrome://settings or file:///etc/passwd.
This violates the core security boundary of chrome-untrusted://, which is designed to process untrusted content without having privileges greater than a standard web page.
Technical Details & Step-by-Step Root Cause
- Incorrect Default Permissions: When a
chrome-untrusted://page loads, aWebUIImplobject is created. In its constructor (content/browser/webui/web_ui_impl.cc),requestable_schemes_is initialized by default to includekChromeUIScheme(chrome://) andurl::kFileScheme(file://). - Incomplete Privilege Dropping: The browser creates an
UntrustedWebUIController(e.g.,ReadAnythingUntrustedUI) for the page. WhileUntrustedWebUIControllercorrectly clears Mojo bindings (web_ui->SetBindings(...)), it fails to clear the privilegedrequestable_schemes_from the underlyingWebUIImpl. - Process-Level Granting: When the navigation commits,
RenderFrameHostImpl::SetWebUIiterates overweb_ui_->GetRequestableSchemes()and explicitly grants the untrusted renderer process permission to request these schemes viaChildProcessSecurityPolicyImpl::GrantRequestScheme. - Bypassing URL Filters: If a compromised renderer sends an
OpenURLMojo IPC requesting a navigation tochrome://settings,RenderFrameHostImpl::OpenURLvalidates it. Because the scheme was explicitly granted in the previous step,ChildProcessSecurityPolicyImpl::CanRequestURLreturns true, and the URL is not blocked. - Navigation Laundering: The request proceeds to
Navigator::RequestOpenURL. This function contains a flawed check:if (render_frame_host->web_ui()). Since thechrome-untrusted://frame has a WebUI object, the code unconditionally setsparams.is_renderer_initiated = false, laundering the malicious renderer-initiated request into a trusted browser-initiated request. - Forwarding and Execution: The delegate,
ReadAnythingSidePanelWebView::OpenURLFromTab, forwards these laundered parameters directly to the main browser window. Becauseis_renderer_initiatedis false, critical security checks inNavigationRequest(e.g.,!commit_params_->is_browser_initiated && ...) are completely bypassed, and the privileged page is opened.
Potential Attacker Steps
Note: Our tooling agent does not currently have the ability to run code or provide a working proof-of-concept. These are the suggested steps an attacker would follow based on static code analysis.
- The attacker convinces the user to open the Read Anything side panel (or targets a user who uses it).
- The attacker serves malicious web content that triggers a memory corruption or logic bug in the
chrome-untrusted://renderer process parsing the content, achieving Remote Code Execution (RCE) within the sandbox. - Using the compromised renderer, the attacker crafts and sends a
FrameHost::OpenURLMojo IPC to the browser process withurl='file:///etc/passwd'(or a sensitivechrome://page) anddisposition=NEW_FOREGROUND_TAB. - The browser processes the IPC, launders the navigation as browser-initiated, and opens the local file or settings page in the user’s main browser tab.
Suggested Fix
- Fix
UntrustedWebUIController: In the constructor ofui::UntrustedWebUIController(ui/webui/untrusted_web_ui_controller.cc), explicitly clear the requestable schemes inherited fromWebUIImpl. This can be done by adding a method likeweb_ui->ClearRequestableSchemes()or explicitly setting them to an empty list. - Harden
Navigator::RequestOpenURL: The logic inNavigator::RequestOpenURL(content/browser/renderer_host/navigator.cc) should not blindly setparams.is_renderer_initiated = falsefor all WebUIs. It should ideally checkWebUIController::GetTrustPolicy()and only launder navigations for trusted WebUIs.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.