Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper privilege management in Navigation
DescriptionImproper privilege management in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker497957278
Fix commit78ee3d361440 (chromium/src) +85/-14
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
for
chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
modified
ASSERT_TRUE
chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
modified

Files Changed

  • chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc
  • chrome/browser/ui/read_anything/read_anything_immersive_web_view.cc
  • chrome/browser/ui/read_anything/read_anything_side_panel_web_view.cc
From 78ee3d3614403afa6a536b34e6352d04e6ea3976 Mon Sep 17 00:00:00 2001
From: Lauren Winston <[email protected]>
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 <[email protected]>
Commit-Queue: Lauren Winston <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1663023}
---

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) {
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 =
Loading diff…

Regression Test / PoC

shipped with the fix
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) {
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • content/browser/renderer_host/navigator.cc
  • content/browser/webui/web_ui_impl.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • ui/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

  1. Incorrect Default Permissions: When a chrome-untrusted:// page loads, a WebUIImpl object is created. In its constructor (content/browser/webui/web_ui_impl.cc), requestable_schemes_ is initialized by default to include kChromeUIScheme (chrome://) and url::kFileScheme (file://).
  2. Incomplete Privilege Dropping: The browser creates an UntrustedWebUIController (e.g., ReadAnythingUntrustedUI) for the page. While UntrustedWebUIController correctly clears Mojo bindings (web_ui->SetBindings(...)), it fails to clear the privileged requestable_schemes_ from the underlying WebUIImpl.
  3. Process-Level Granting: When the navigation commits, RenderFrameHostImpl::SetWebUI iterates over web_ui_->GetRequestableSchemes() and explicitly grants the untrusted renderer process permission to request these schemes via ChildProcessSecurityPolicyImpl::GrantRequestScheme.
  4. Bypassing URL Filters: If a compromised renderer sends an OpenURL Mojo IPC requesting a navigation to chrome://settings, RenderFrameHostImpl::OpenURL validates it. Because the scheme was explicitly granted in the previous step, ChildProcessSecurityPolicyImpl::CanRequestURL returns true, and the URL is not blocked.
  5. Navigation Laundering: The request proceeds to Navigator::RequestOpenURL. This function contains a flawed check: if (render_frame_host->web_ui()). Since the chrome-untrusted:// frame has a WebUI object, the code unconditionally sets params.is_renderer_initiated = false, laundering the malicious renderer-initiated request into a trusted browser-initiated request.
  6. Forwarding and Execution: The delegate, ReadAnythingSidePanelWebView::OpenURLFromTab, forwards these laundered parameters directly to the main browser window. Because is_renderer_initiated is false, critical security checks in NavigationRequest (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.

  1. The attacker convinces the user to open the Read Anything side panel (or targets a user who uses it).
  2. 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.
  3. Using the compromised renderer, the attacker crafts and sends a FrameHost::OpenURL Mojo IPC to the browser process with url='file:///etc/passwd' (or a sensitive chrome:// page) and disposition=NEW_FOREGROUND_TAB.
  4. 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

  1. Fix UntrustedWebUIController: In the constructor of ui::UntrustedWebUIController (ui/webui/untrusted_web_ui_controller.cc), explicitly clear the requestable schemes inherited from WebUIImpl. This can be done by adding a method like web_ui->ClearRequestableSchemes() or explicitly setting them to an empty list.
  2. Harden Navigator::RequestOpenURL: The logic in Navigator::RequestOpenURL (content/browser/renderer_host/navigator.cc) should not blindly set params.is_renderer_initiated = false for all WebUIs. It should ideally check WebUIController::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.

View on issue tracker