Medium watch CVSS 8.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper privilege management in Navigation
DescriptionImproper privilege management in Navigation
ComponentNavigation
Sub-componentReading mode
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker497957278
Fix commit78ee3d361440 (chromium/src) +85/-14
CWECWE-269
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-30 (per vendor advisory)
Fix landed2026-07-16 (public git)
Disclosed2026-08-25
Patch gap40 days — fix public in source before the advisory

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 own WebContents.
`WebContentsDelegate::OpenURLFromTab`
the browser-side hook called when a hosted WebContents asks 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 to OpenURLFromTab. Its URL comes from the content running inside the Reading Mode WebContents.
`GURL::SchemeIsHTTPOrHTTPS`
an allowlist check that is true only for http: and https: 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.

Key insight
A blocklist of “dangerous” schemes protected a browser-side navigation forwarder, so any privileged or opaque scheme left off the list passed through. The fix turns the check into a positive allowlist of http/https, which is all distilled web content should ever need.

Attack Path

  1. Attacker page with crafted links The attacker serves a page whose content includes links with non-web targets (for example devtools://, chrome-extension://, data: or filesystem: URLs, as in the regression test) that survive into the Reading Mode view (inferred; the patch does not show how distillation handles link schemes).
  2. 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.
  3. Link activation forwarded by the browser Clicking such a link in Reading Mode makes the hosted WebContents call OpenURLFromTab. Before the patch, any scheme other than chrome:, chrome-untrusted:, file: and javascript: was forwarded to the main browser.
  4. 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

The bug lets content shown in Reading Mode get the browser process to open URLs with privileged or non-web schemes that ordinary web pages are blocked from navigating to, which the advisory calls improper privilege management in Navigation. It needs the user to open Reading Mode on attacker-controlled content and to activate a link. A compromised Reading Mode renderer could presumably request such URLs directly (inferred). What can be done further depends on the opened target (for example DevTools or extension pages) and is not shown by the patch (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.3.

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_immersive_web_view.cc
  • chrome/browser/ui/read_anything/read_anything_side_panel_web_view.cc
  • chrome/browser/ui/read_anything/read_anything_controller_browsertest.cc

Audit Directions

  • Scheme blocklists in navigation forwarders
    Search WebContentsDelegate::OpenURLFromTab, AddNewContents, and similar overrides in side panels, WebUI bubbles and overlays for SchemeIs(...) blocklists, and replace them with allowlists suited to what the hosted content legitimately needs.
  • Untrusted content in trusted surfaces
    Features 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 fixes
    This is a follow-up to an earlier partial fix. When reviewing a fix that enumerates bad inputs, list every scheme GURL can parse (blob, filesystem, data, about, devtools, chrome-extension, view-source, and others) and confirm each one is handled.
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_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) {
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