Low watch CVSS 6.5 chrome Input Validation 🔧 Commit mapped

Overview

Low
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in ReadingList
DescriptionImproper input validation in ReadingList
ComponentReadingList
Sub-componentReading List
Bug ClassInput Validation
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.
Tracker517394060
Fix commit0eb9a022aa95 (chromium/src) +67/-12
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedOrange Tsai (@orange_8361) of DEVCORE Research Team
Reported2026-05-28 (per vendor advisory)
Fix landed2026-07-09 (public git)
Disclosed2026-08-25
Patch gap47 days — fix public in source before the advisory

Background

Reading List side panel
a Chrome WebUI surface backed by ReadingListPageHandler that lists saved URLs and lets the user open them via a Mojo OpenURL call from the renderer-side WebUI.
`ReadingListPageHandler::OpenURL`
the browser-process handler that receives a URL and click modifiers from the untrusted WebUI page and navigates a browser to it.
`ReadingListModel::GetEntryByURL`
a lookup returning the ReadingListEntry for a given URL, or null when the URL is not a saved reading-list item.
`webui::GetBrowserWindowInterface`
a WebUI utility that resolves the BrowserWindowInterface from the handler’s own web_contents(), i.e. the window actually hosting the side panel.

Root Cause Analysis

ReadingListPageHandler::OpenURL is a Mojo endpoint callable by the (comparatively untrusted) Reading List WebUI page, and pre-patch it navigated to any URL passed in without confirming that URL was actually a reading-list entry. It also targeted GlobalBrowserCollection::GetInstance()->GetLastActiveBrowser() — an ambient “last active browser” rather than the window that owns the side panel — so a navigation could be injected into a different browser context than the one the WebUI belongs to. The commit message states this allowed exploits where “navigations can be sent from the wrong context to trigger JS alerts,” bypassing the invariant that the side panel should only ever open its own saved entries.

The fix adds an early GetEntryByURL(url) check at the top of the function and returns immediately when no matching entry exists, and switches the navigation target to webui::GetBrowserWindowInterface(web_contents()) so the URL opens in the correct owning window. Because alert() (and similar JS) calls cannot be represented as a reading-list entry URL, requiring a matching entry closes the attack surface.

Key insight
The handler trusted the URL argument supplied over Mojo and navigated an ambiently-selected “last active” browser instead of validating that the URL was a real reading-list entry opened in its own window; the fix adds an early-return GetEntryByURL guard and resolves the target window from web_contents().

Attack Path

  1. Reach the Mojo endpoint A compromised or crafted Reading List WebUI context invokes ReadingListPageHandler::OpenURL with an attacker-chosen URL and click modifiers.
  2. Bypass the missing validation Pre-patch there is no check that the URL belongs to the reading list, so the arbitrary URL flows straight into content::OpenURLParams.
  3. Navigate the wrong context The handler resolves the target via GlobalBrowserCollection::GetLastActiveBrowser(), letting the navigation land in a browser/context other than the one hosting the side panel.
  4. Trigger cross-context JS The injected navigation is used to fire JS such as alert() from the wrong context (per the commit message), an effect impossible for a genuine reading-list entry URL.

Impact Assessment

An attacker able to drive the Reading List WebUI could cause navigations to arbitrary URLs delivered into the wrong browser context, enabling cross-context JavaScript execution such as spurious alert() dialogs as described by the commit message. The vulnerability is reached in the browser process via the Reading List Mojo interface and requires interaction with the side-panel WebUI. The advisory classifies this as improper input validation in Reading List; no exploited-in-the-wild status or further escalation is stated, and any broader spoofing/UI-redress impact is (inferred).

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler.cc
modified
TEST_F
chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc
modified

Files Changed

  • chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc
  • chrome/browser/ui/webui/side_panel/reading_list/BUILD.gn
  • chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler.cc

Audit Directions

  • Unvalidated URLs over WebUI Mojo
    Audit other side-panel/WebUI handlers that accept a URL from the renderer and navigate without confirming the URL belongs to that surface’s own data model.
  • Ambient browser/window selection
    Flag uses of GlobalBrowserCollection::GetLastActiveBrowser() (or similar “last active” globals) in handlers that should act on their own web_contents()-derived window, as they can act on the wrong context.
  • Entry-existence as authorization
    Look for handlers that perform an action (navigate, delete, mark) and only afterward, or never, verify the target item exists in the backing model.
From 0eb9a022aa950457c60998c7808c749164160690 Mon Sep 17 00:00:00 2001
From: Alison Gale <agale@chromium.org>
Date: Thu, 09 Jul 2026 08:01:22 -0700
Subject: [PATCH] [Reading List] Only open urls from the reading list

- Use webui util for getting BWI
- Early return if the url isn't from the reading list

This prevents exploits where navigations can be sent from the wrong
context to trigger JS alerts. Since alert calls can't be in the reading
list, this will handle the issue.

Bug: 517394060
Change-Id: I6ce0d56f13c88ffd5aee5795090533d49715b9cc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8060466
Commit-Queue: Alison Gale <agale@chromium.org>
Reviewed-by: Caroline Rising <corising@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1659552}
---
diff --git a/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler.cc b/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler.cc
index 90c6ec7a..b8d682ac 100644
--- a/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler.cc
+++ b/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler.cc
@@ -32,6 +32,7 @@
 #include "chrome/browser/ui/profiles/profile_view_utils.h"
 #include "chrome/browser/ui/ui_features.h"
 #include "chrome/browser/ui/webui/side_panel/reading_list/reading_list_ui.h"
+#include "chrome/browser/ui/webui/webui_embedding_context.h"
 #include "chrome/common/webui_url_constants.h"
 #include "chrome/grit/generated_resources.h"
 #include "components/profile_metrics/browser_profile_type.h"
@@ -179,9 +180,10 @@
 void ReadingListPageHandler::OpenURL(
     const GURL& url,
     ui::mojom::ClickModifiersPtr click_modifiers) {
-  BrowserWindowInterface* browser =
-      GlobalBrowserCollection::GetInstance()->GetLastActiveBrowser();
-  if (!browser) {
+  // Only support opening reading list entry URLS.
+  scoped_refptr<const ReadingListEntry> entry =
+      reading_list_model_->GetEntryByURL(url);
+  if (!entry) {
     return;
   }
 
@@ -193,18 +195,18 @@
 
   content::OpenURLParams params(url, content::Referrer(), open_location,
                                 ui::PAGE_TRANSITION_AUTO_BOOKMARK, false);
-  browser->GetBrowserForMigrationOnly()->OpenURL(
-      params,
-      /*navigation_handle_callback=*/{});
 
-  scoped_refptr<const ReadingListEntry> entry =
-      reading_list_model_->GetEntryByURL(url);
-  if (entry) {
-    base::RecordAction(base::UserMetricsAction(
-        entry->IsRead() ? "DesktopReadingList.Navigation.FromReadList"
-                        : "DesktopReadingList.Navigation.FromUnreadList"));
+  auto* browser_window_interface =
+      webui::GetBrowserWindowInterface(web_contents());
+  if (!browser_window_interface) {
+    return;
   }
+  browser_window_interface->OpenURL(params,
+                                    /*navigation_handle_callback=*/{});
 
+  base::RecordAction(base::UserMetricsAction(
+      entry->IsRead() ? "DesktopReadingList.Navigation.FromReadList"
+                      : "DesktopReadingList.Navigation.FromUnreadList"));
   base::RecordAction(
       base::UserMetricsAction("SidePanel.ReadingList.Navigation"));
   RecordBookmarkLaunch(
diff --git a/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc b/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc
index 8f9ffdc9..865d99a 100644
--- a/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc
+++ b/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc
@@ -10,11 +10,13 @@
 
 #include "base/memory/raw_ptr.h"
 #include "base/test/bind.h"
+#include "base/test/metrics/user_action_tester.h"
 #include "chrome/app/chrome_command_ids.h"
 #include "chrome/browser/prefs/incognito_mode_prefs.h"
 #include "chrome/browser/reading_list/reading_list_model_factory.h"
 #include "chrome/browser/ui/browser_commands.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
+#include "chrome/browser/ui/webui/webui_embedding_context.h"
 #include "chrome/common/webui_url_constants.h"
 #include "chrome/test/base/browser_with_test_window_test.h"
 #include "chrome/test/base/test_browser_window.h"
@@ -99,6 +101,7 @@
 
     web_contents_ = content::WebContents::Create(
         content::WebContents::CreateParams(profile()));
+    webui::SetBrowserWindowInterface(web_contents_.get(), browser());
     test_web_ui_ = std::make_unique<content::TestWebUI>();
     test_web_ui_->set_web_contents(web_contents_.get());
 
@@ -124,6 +127,7 @@
   }
 
   void TearDown() override {
+    webui::SetBrowserWindowInterface(web_contents_.get(), nullptr);
     incognito_browser_.reset();
     handler_.reset();
     test_web_ui_.reset();
@@ -262,6 +266,53 @@
       /* expected_read_data= */ {});
 }
 
+TEST_F(TestReadingListPageHandlerTest, OpenURLNotInReadingList) {
+  const GURL not_in_reading_list_url("http://not-in-reading-list.com");
+  EXPECT_FALSE(model()->GetEntryByURL(not_in_reading_list_url));
+
+  base::UserActionTester user_action_tester;
+
+  const int tab_count_before = browser()->tab_strip_model()->count();
+  content::WebContents* active_web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+  const GURL active_url_before = active_web_contents->GetVisibleURL();
+
+  // Try to open a URL that is not in the reading list.
+  handler()->OpenURL(not_in_reading_list_url, GetClickModifiers());
+
+  // Check that the URL was not opened (tab count and active tab URL remain
+  // unchanged).
+  EXPECT_EQ(browser()->tab_strip_model()->count(), tab_count_before);
+  EXPECT_EQ(active_web_contents->GetVisibleURL(), active_url_before);
+
+  // Try to open with middle click (which would open a new tab if URL were in
+  // reading list).
+  auto click_modifiers = GetClickModifiers();
+  click_modifiers->middle_button = true;
+  handler()->OpenURL(not_in_reading_list_url, std::move(click_modifiers));
+
+  // Check that no new tab was opened.
+  EXPECT_EQ(browser()->tab_strip_model()->count(), tab_count_before);
+
+  // Verify that no navigation metrics were recorded.
+  EXPECT_EQ(
+      user_action_tester.GetActionCount("SidePanel.ReadingList.Navigation"), 0);
+
+  // Expect ItemsChanged to be called four times from the two AddEntry calls in
+  // SetUp().
+  EXPECT_CALL(page_, ItemsChanged(testing::_)).Times(4);
+  // Expect CurrentPageActionButtonStateChanged to be called once.
+  EXPECT_CALL(page_, CurrentPageActionButtonStateChanged(testing::_)).Times(1);
+
+  // Get Read later entries.
+  GetAndVerifyReadLaterEntries(
+      /* unread_size= */ 2u, /* read_size= */ 0u,
+      /* expected_unread_data= */
+      {std::make_pair(GURL(kTabUrl3), kTabName3),
+       std::make_pair(GURL(kTabUrl1), kTabName1)},
+      /* expected_read_data= */ {});
+}
+
 TEST_F(TestReadingListPageHandlerTest, UpdateReadStatus) {
   handler()->UpdateReadStatus(GURL(kTabUrl3), true);
 
diff --git a/chrome/browser/ui/webui/side_panel/reading_list/BUILD.gn b/chrome/browser/ui/webui/side_panel/reading_list/BUILD.gn
index 9a5dce2..f3019566 100644
--- a/chrome/browser/ui/webui/side_panel/reading_list/BUILD.gn
+++ b/chrome/browser/ui/webui/side_panel/reading_list/BUILD.gn
@@ -52,6 +52,7 @@
     "//chrome/browser/ui/profiles",
     "//chrome/browser/ui/tabs:tab_strip",
     "//chrome/browser/ui/webui:favicon_source",
+    "//chrome/browser/ui/webui:webui_util",
     "//chrome/browser/ui/webui_browser",
     "//chrome/common",
     "//components/favicon_base",
@@ -73,6 +74,7 @@
     "//base/test:test_support",
     "//chrome/browser/prefs",
     "//chrome/browser/reading_list",
+    "//chrome/browser/ui/webui:webui_util",
     "//chrome/test:test_support",
     "//components/reading_list/core:test_support",
     "//content/test:test_support",
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc b/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc
index 8f9ffdc9..865d99a 100644
--- a/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc
+++ b/chrome/browser/ui/webui/side_panel/reading_list/reading_list_page_handler_unittest.cc
@@ -10,11 +10,13 @@
 
 #include "base/memory/raw_ptr.h"
 #include "base/test/bind.h"
+#include "base/test/metrics/user_action_tester.h"
 #include "chrome/app/chrome_command_ids.h"
 #include "chrome/browser/prefs/incognito_mode_prefs.h"
 #include "chrome/browser/reading_list/reading_list_model_factory.h"
 #include "chrome/browser/ui/browser_commands.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
+#include "chrome/browser/ui/webui/webui_embedding_context.h"
 #include "chrome/common/webui_url_constants.h"
 #include "chrome/test/base/browser_with_test_window_test.h"
 #include "chrome/test/base/test_browser_window.h"
@@ -99,6 +101,7 @@
 
     web_contents_ = content::WebContents::Create(
         content::WebContents::CreateParams(profile()));
+    webui::SetBrowserWindowInterface(web_contents_.get(), browser());
     test_web_ui_ = std::make_unique<content::TestWebUI>();
     test_web_ui_->set_web_contents(web_contents_.get());
 
@@ -124,6 +127,7 @@
   }
 
   void TearDown() override {
+    webui::SetBrowserWindowInterface(web_contents_.get(), nullptr);
     incognito_browser_.reset();
     handler_.reset();
     test_web_ui_.reset();
@@ -262,6 +266,53 @@
       /* expected_read_data= */ {});
 }
 
+TEST_F(TestReadingListPageHandlerTest, OpenURLNotInReadingList) {
+  const GURL not_in_reading_list_url("http://not-in-reading-list.com");
+  EXPECT_FALSE(model()->GetEntryByURL(not_in_reading_list_url));
+
+  base::UserActionTester user_action_tester;
+
+  const int tab_count_before = browser()->tab_strip_model()->count();
+  content::WebContents* active_web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+  const GURL active_url_before = active_web_contents->GetVisibleURL();
+
+  // Try to open a URL that is not in the reading list.
+  handler()->OpenURL(not_in_reading_list_url, GetClickModifiers());
+
+  // Check that the URL was not opened (tab count and active tab URL remain
+  // unchanged).
+  EXPECT_EQ(browser()->tab_strip_model()->count(), tab_count_before);
+  EXPECT_EQ(active_web_contents->GetVisibleURL(), active_url_before);
+
+  // Try to open with middle click (which would open a new tab if URL were in
+  // reading list).
+  auto click_modifiers = GetClickModifiers();
+  click_modifiers->middle_button = true;
+  handler()->OpenURL(not_in_reading_list_url, std::move(click_modifiers));
+
+  // Check that no new tab was opened.
+  EXPECT_EQ(browser()->tab_strip_model()->count(), tab_count_before);
+
+  // Verify that no navigation metrics were recorded.
+  EXPECT_EQ(
+      user_action_tester.GetActionCount("SidePanel.ReadingList.Navigation"), 0);
+
+  // Expect ItemsChanged to be called four times from the two AddEntry calls in
+  // SetUp().
+  EXPECT_CALL(page_, ItemsChanged(testing::_)).Times(4);
+  // Expect CurrentPageActionButtonStateChanged to be called once.
+  EXPECT_CALL(page_, CurrentPageActionButtonStateChanged(testing::_)).Times(1);
+
+  // Get Read later entries.
+  GetAndVerifyReadLaterEntries(
+      /* unread_size= */ 2u, /* read_size= */ 0u,
+      /* expected_unread_data= */
+      {std::make_pair(GURL(kTabUrl3), kTabName3),
+       std::make_pair(GURL(kTabUrl1), kTabName1)},
+      /* expected_read_data= */ {});
+}
+
 TEST_F(TestReadingListPageHandlerTest, UpdateReadStatus) {
   handler()->UpdateReadStatus(GURL(kTabUrl3), true);
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.