CVE-2026-87475
Overview
Files Changed
chrome/browser/ui/browser_commands.ccchrome/browser/ui/browser_commands_browsertest.ccchrome/browser/ui/views/location_bar/location_bar_view.ccchrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc
Patch
From ee8d8601246e5075b391cfe76f586f850fc0da44 Mon Sep 17 00:00:00 2001
From: Jan Keitel <jkeitel@google.com>
Date: Wed, 29 Jul 2026 04:06:50 -0700
Subject: [PATCH] Only allow safe schemes when opening from middle click
Bug: 507225626
Change-Id: I83580bf4b7d94a084340c4a03f85512db0655cb3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8118569
Commit-Queue: Jan Keitel <jkeitel@google.com>
Reviewed-by: Kunal Daftari <kunaldaftari@google.com>
Reviewed-by: David Pennington <dpenning@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1670118}
---
diff --git a/chrome/browser/ui/browser_commands.cc b/chrome/browser/ui/browser_commands.cc
index d6e1f1f8..67acdf3 100644
--- a/chrome/browser/ui/browser_commands.cc
+++ b/chrome/browser/ui/browser_commands.cc
@@ -182,6 +182,7 @@
#include "components/zoom/page_zoom.h"
#include "components/zoom/zoom_controller.h"
#include "content/public/browser/browsing_data_remover.h"
+#include "content/public/browser/child_process_security_policy.h"
#include "content/public/browser/devtools_agent_host.h"
#include "content/public/browser/navigation_controller.h"
#include "content/public/browser/navigation_entry.h"
@@ -1335,7 +1336,10 @@
->Classify(text, false, false,
metrics::OmniboxEventProto::BLANK, &match,
nullptr);
- if (match.destination_url.is_valid()) {
+ if (match.destination_url.is_valid() &&
+ content::ChildProcessSecurityPolicy::GetInstance()
+ ->IsWebSafeScheme(
+ std::string(match.destination_url.scheme()))) {
browser_weak->tab_strip_model()->delegate()->AddTabAt(
match.destination_url, -1, true);
}
diff --git a/chrome/browser/ui/browser_commands_browsertest.cc b/chrome/browser/ui/browser_commands_browsertest.cc
index 404302e..4a97ed0 100644
--- a/chrome/browser/ui/browser_commands_browsertest.cc
+++ b/chrome/browser/ui/browser_commands_browsertest.cc
@@ -38,7 +38,10 @@
#include "content/public/common/content_paths.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/test_navigation_observer.h"
#include "net/dns/mock_host_resolver.h"
+#include "ui/base/clipboard/clipboard.h"
+#include "ui/base/clipboard/scoped_clipboard_writer.h"
#include "ui/base/ui_base_features.h"
namespace chrome {
@@ -791,4 +794,60 @@
EXPECT_EQ(1, browser()->tab_strip_model()->count());
}
+#if BUILDFLAG(IS_LINUX)
+// Tests that unsafe schemes are not allowed when opening new tabs from a
+// clipboard URL.
+IN_PROC_BROWSER_TEST_F(BrowserCommandsTest,
+ NewTabFromClipboardURLBlocksUnsafeSchemes) {
+ if (!ui::Clipboard::IsSupportedClipboardBuffer(
+ ui::ClipboardBuffer::kSelection)) {
+ return;
+ }
+
+ TabStripModel* tab_strip_model = browser()->tab_strip_model();
+ int initial_tab_count = tab_strip_model->count();
+
+ // Try file:// URL. Note: ui::Clipboard::ReadText is asynchronous on Linux, so
+ // we must pump the runloop to allow the callback to run and be rejected.
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(u"file:///etc/passwd");
+ }
+
+ chrome::NewTabFromClipboardURL(browser());
+ base::RunLoop().RunUntilIdle();
+ EXPECT_EQ(initial_tab_count, tab_strip_model->count());
+
+ // Try chrome:// URL.
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(u"chrome://version");
+ }
+
+ chrome::NewTabFromClipboardURL(browser());
+ base::RunLoop().RunUntilIdle();
+ EXPECT_EQ(initial_tab_count, tab_strip_model->count());
+
+ // Try a safe URL (http). We explicitly observe both the new tab addition and
+ // the navigation completion instead of guessing runloop cycles.
+ GURL safe_url = https_server_.GetURL("a.test", "/title1.html");
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(base::UTF8ToUTF16(safe_url.spec()));
+ }
+
+ ui_test_utils::TabAddedWaiter tab_waiter(browser());
+ content::TestNavigationObserver observer(safe_url);
+ observer.StartWatchingNewWebContents();
+
+ chrome::NewTabFromClipboardURL(browser());
+ tab_waiter.Wait();
+ observer.Wait();
+
+ EXPECT_EQ(initial_tab_count + 1, tab_strip_model->count());
+ EXPECT_EQ(safe_url,
+ tab_strip_model->GetActiveWebContents()->GetLastCommittedURL());
+}
+#endif
+
} // namespace chrome
diff --git a/chrome/browser/ui/views/location_bar/location_bar_view.cc b/chrome/browser/ui/views/location_bar/location_bar_view.cc
index c101d301..c7f9dad1 100644
--- a/chrome/browser/ui/views/location_bar/location_bar_view.cc
+++ b/chrome/browser/ui/views/location_bar/location_bar_view.cc
@@ -19,6 +19,7 @@
#include "chrome/app/vector_icons/vector_icons.h"
#include "chrome/browser/actor/ui/actor_ui_window_controller.h"
#include "chrome/browser/autocomplete/aim_eligibility_service_factory.h"
+#include "chrome/browser/autocomplete/autocomplete_classifier_factory.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/command_updater.h"
#include "chrome/browser/page_info/merchant_trust_service_factory.h"
@@ -101,6 +102,7 @@
#include "components/contextual_search/input_state_model.h"
#include "components/favicon/content/content_favicon_driver.h"
#include "components/lens/lens_features.h"
+#include "components/omnibox/browser/autocomplete_classifier.h"
#include "components/omnibox/browser/location_bar_model.h"
#include "components/omnibox/browser/omnibox_client.h"
#include "components/omnibox/browser/omnibox_field_trial.h"
@@ -116,6 +118,7 @@
#include "components/search_engines/template_url_service.h"
#include "components/security_state/content/security_state_tab_helper.h"
#include "components/security_state/core/security_state.h"
+#include "content/public/browser/child_process_security_policy.h"
#include "content/public/browser/navigation_entry.h"
#include "content/public/browser/render_widget_host_view.h"
#include "content/public/browser/web_contents.h"
@@ -2173,6 +2176,15 @@
return;
}
+ AutocompleteMatch match;
+ AutocompleteClassifierFactory::GetForProfile(GetProfile())
+ ->Classify(text, false, false, metrics::OmniboxEventProto::BLANK, &match,
+ nullptr);
+ if (!content::ChildProcessSecurityPolicy::GetInstance()->IsWebSafeScheme(
+ std::string(match.destination_url.scheme()))) {
+ return;
+ }
+
GetOmniboxController()->edit_model()->PasteAndGo(text, event_timestamp);
}
diff --git a/chrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc b/chrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc
index 38d81f2..6a0ecd60 100644
--- a/chrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc
+++ b/chrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc
@@ -754,3 +754,54 @@
EXPECT_TRUE(regular_model_action->GetText().empty());
EXPECT_FALSE(regular_model_action->GetImage().IsEmpty());
}
+
+// Tests that unsafe schemes are not allowed to be opened from middle clicks.
+IN_PROC_BROWSER_TEST_F(LocationBarViewBrowserTest,
+ MiddleClickPasteAndGoBlocksUnsafeSchemes) {
+ if (!ui::Clipboard::IsMiddleClickPasteEnabled() ||
+ !ui::Clipboard::IsSupportedClipboardBuffer(
+ ui::ClipboardBuffer::kSelection)) {
+ return;
+ }
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL start_url = embedded_test_server()->GetURL("/title1.html");
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), start_url));
+
+ LocationBarView* location_bar_view = GetLocationBarView();
+ LocationIconView* location_icon_view =
+ location_bar_view->location_icon_view();
+
+ // Try file:// URL. Note: ui::Clipboard::ReadText is asynchronous on Linux, so
+ // we must pump the runloop to allow the callback to run and be rejected.
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(u"file:///etc/passwd");
+ }
+
+ ui::MouseEvent middle_click_event(ui::EventType::kMousePressed, gfx::Point(),
+ gfx::Point(), base::TimeTicks::Now(),
+ ui::EF_MIDDLE_MOUSE_BUTTON,
+ ui::EF_MIDDLE_MOUSE_BUTTON);
+ location_icon_view->OnMousePressed(middle_click_event);
+ base::RunLoop().RunUntilIdle();
+
+ EXPECT_EQ(start_url, browser()
+ ->tab_strip_model()
Regression Test / PoC
diff --git a/chrome/browser/ui/browser_commands_browsertest.cc b/chrome/browser/ui/browser_commands_browsertest.cc
index 404302e..4a97ed0 100644
--- a/chrome/browser/ui/browser_commands_browsertest.cc
+++ b/chrome/browser/ui/browser_commands_browsertest.cc
@@ -38,7 +38,10 @@
#include "content/public/common/content_paths.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/test_navigation_observer.h"
#include "net/dns/mock_host_resolver.h"
+#include "ui/base/clipboard/clipboard.h"
+#include "ui/base/clipboard/scoped_clipboard_writer.h"
#include "ui/base/ui_base_features.h"
namespace chrome {
@@ -791,4 +794,60 @@
EXPECT_EQ(1, browser()->tab_strip_model()->count());
}
+#if BUILDFLAG(IS_LINUX)
+// Tests that unsafe schemes are not allowed when opening new tabs from a
+// clipboard URL.
+IN_PROC_BROWSER_TEST_F(BrowserCommandsTest,
+ NewTabFromClipboardURLBlocksUnsafeSchemes) {
+ if (!ui::Clipboard::IsSupportedClipboardBuffer(
+ ui::ClipboardBuffer::kSelection)) {
+ return;
+ }
+
+ TabStripModel* tab_strip_model = browser()->tab_strip_model();
+ int initial_tab_count = tab_strip_model->count();
+
+ // Try file:// URL. Note: ui::Clipboard::ReadText is asynchronous on Linux, so
+ // we must pump the runloop to allow the callback to run and be rejected.
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(u"file:///etc/passwd");
+ }
+
+ chrome::NewTabFromClipboardURL(browser());
+ base::RunLoop().RunUntilIdle();
+ EXPECT_EQ(initial_tab_count, tab_strip_model->count());
+
+ // Try chrome:// URL.
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(u"chrome://version");
+ }
+
+ chrome::NewTabFromClipboardURL(browser());
+ base::RunLoop().RunUntilIdle();
+ EXPECT_EQ(initial_tab_count, tab_strip_model->count());
+
+ // Try a safe URL (http). We explicitly observe both the new tab addition and
+ // the navigation completion instead of guessing runloop cycles.
+ GURL safe_url = https_server_.GetURL("a.test", "/title1.html");
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(base::UTF8ToUTF16(safe_url.spec()));
+ }
+
+ ui_test_utils::TabAddedWaiter tab_waiter(browser());
+ content::TestNavigationObserver observer(safe_url);
+ observer.StartWatchingNewWebContents();
+
+ chrome::NewTabFromClipboardURL(browser());
+ tab_waiter.Wait();
+ observer.Wait();
+
+ EXPECT_EQ(initial_tab_count + 1, tab_strip_model->count());
+ EXPECT_EQ(safe_url,
+ tab_strip_model->GetActiveWebContents()->GetLastCommittedURL());
+}
+#endif
+
} // namespace chrome
diff --git a/chrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc b/chrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc
index 38d81f2..6a0ecd60 100644
--- a/chrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc
+++ b/chrome/browser/ui/views/location_bar/location_bar_view_browsertest.cc
@@ -754,3 +754,54 @@
EXPECT_TRUE(regular_model_action->GetText().empty());
EXPECT_FALSE(regular_model_action->GetImage().IsEmpty());
}
+
+// Tests that unsafe schemes are not allowed to be opened from middle clicks.
+IN_PROC_BROWSER_TEST_F(LocationBarViewBrowserTest,
+ MiddleClickPasteAndGoBlocksUnsafeSchemes) {
+ if (!ui::Clipboard::IsMiddleClickPasteEnabled() ||
+ !ui::Clipboard::IsSupportedClipboardBuffer(
+ ui::ClipboardBuffer::kSelection)) {
+ return;
+ }
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL start_url = embedded_test_server()->GetURL("/title1.html");
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), start_url));
+
+ LocationBarView* location_bar_view = GetLocationBarView();
+ LocationIconView* location_icon_view =
+ location_bar_view->location_icon_view();
+
+ // Try file:// URL. Note: ui::Clipboard::ReadText is asynchronous on Linux, so
+ // we must pump the runloop to allow the callback to run and be rejected.
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(u"file:///etc/passwd");
+ }
+
+ ui::MouseEvent middle_click_event(ui::EventType::kMousePressed, gfx::Point(),
+ gfx::Point(), base::TimeTicks::Now(),
+ ui::EF_MIDDLE_MOUSE_BUTTON,
+ ui::EF_MIDDLE_MOUSE_BUTTON);
+ location_icon_view->OnMousePressed(middle_click_event);
+ base::RunLoop().RunUntilIdle();
+
+ EXPECT_EQ(start_url, browser()
+ ->tab_strip_model()
+ ->GetActiveWebContents()
+ ->GetLastCommittedURL());
+
+ // Try chrome:// URL.
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kSelection);
+ writer.WriteText(u"chrome://version");
+ }
+
+ location_icon_view->OnMousePressed(middle_click_event);
+ base::RunLoop().RunUntilIdle();
+
+ EXPECT_EQ(start_url, browser()
+ ->tab_strip_model()
+ ->GetActiveWebContents()
+ ->GetLastCommittedURL());
+}
Original Bug Report
Renderer-controlled browser navigation via middle-click on Linux omnibox icon or tab button
Project Fortify, 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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: On Linux, a web page can silently overwrite the X11 PRIMARY selection clipboard without a user gesture by selecting text during non-activating input events like mousemove. If a user middle-clicks the omnibox lock/tune icon or the New Tab button, the browser performs a browser-initiated navigation to the poisoned clipboard content. This bypasses restrictions, allowing a renderer to force navigation to sensitive schemes like file:// or chrome://.
Affected files:
chrome/browser/ui/views/location_bar/location_bar_view.ccchrome/browser/ui/views/tabs/tab_strip.ccchrome/browser/ui/views/location_bar/location_icon_view.cccontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/render_widget_host_view_aura.cccomponents/omnibox/browser/omnibox_text_util.ccchrome/browser/ui/omnibox/omnibox_edit_model.ccchrome/browser/ui/views/frame/horizontal_tab_strip_region_view.cc
Estimated timestamp from git blame: 2026-04-17
Vulnerability Overview
On Linux systems supporting the X11 PRIMARY selection clipboard, a web page can programmatically overwrite this clipboard buffer without requiring transient user activation (a user gesture). It achieves this by calling input.select() during a common, non-activating input event such as mousemove.
This clipboard content is then utilized by certain browser UI components—specifically the omnibox lock/tune icon and the New Tab (+) button—when they are middle-clicked. Because these UI components initiate a navigation using the clipboard content as if it were user-typed input, it allows a renderer to force a browser-initiated navigation to sensitive schemes such as file://, chrome://, and devtools://, bypassing the security restrictions that normally block web-to-local navigations.
Technical Analysis
1. Renderer Poisoning of PRIMARY Selection
In Blink, programmatic selection changes usually require an input event to be synced to the browser. While a simple setInterval will fail the HandlingInputEvent() check in RenderFrameImpl::DidChangeSelection (content/renderer/render_frame_impl.cc:4377), an attacker can bypass this by calling input.select() inside a mousemove event handler. Because mousemove is an input event, the check passes, and RenderFrameImpl::SetSelectedText triggers the mojom::LocalFrameHost::TextSelectionChanged IPC.
2. Browser-Side Silent Clipboard Write
The browser process receives this IPC in RenderFrameHostImpl::TextSelectionChanged. It forwards the selection to RenderWidgetHostViewAura::OnTextSelectionChanged (content/browser/renderer_host/render_widget_host_view_aura.cc:3372). On Linux, this method directly writes the selected text to the system’s PRIMARY selection (ui::ClipboardBuffer::kSelection) via ui::ScopedClipboardWriter. Crucially, this path does not perform any transient user activation check, allowing the background script to silently overwrite the middle-click clipboard every time the mouse moves.
3. Vulnerable Sinks
The attacker’s payload (e.g., a restricted URL) now sits in the PRIMARY selection. If the user intends to middle-click-paste their own text or simply misses a click, they may hit one of two vulnerable sinks:
- Location Icon Middle-Click: Middle-clicking the tune/lock icon triggers
LocationBarView::OnLocationIconPressed(chrome/browser/ui/views/location_bar/location_bar_view.cc:2217). This reads the PRIMARY selection and callsPasteAndGo. WhileSanitizeTextForPasteremovesjavascript:URLs, it does not block other sensitive schemes. - New Tab Button Middle-Click: Middle-clicking the New Tab (+) button triggers
TabStrip::NewTabButtonPressedand callschrome::NewTabFromClipboardURL(chrome/browser/ui/browser_commands.cc:1184).
4. Navigation Bypass
Both sinks pass the attacker-controlled text to the AutocompleteClassifier. Schemes like chrome:// and file:// are recognized as valid. The browser then constructs a NavigateParams object to initiate the navigation. Because the navigation originates from the browser UI, the is_renderer_initiated flag defaults to false. The navigation subsystem treats this as a trusted, browser-initiated action, successfully bypassing the security checks that normally prevent renderers from navigating to restricted URLs.
Potential Reproduction Steps
Note: Our tooling agent does not have the ability to run code, but the following steps are suggested based on code analysis.
- On a Linux system (X11 or Wayland with primary selection), host a page with the following script:
let input = document.createElement('input'); input.style.position = 'absolute'; input.style.left = '-9999px'; document.body.appendChild(input); // Hijack the primary selection on mouse movement to bypass HandlingInputEvent() checks document.addEventListener('mousemove', () => { input.value = 'file:///etc/passwd'; // Or chrome://crash, chrome://settings input.select(); }); - Open the page in Chrome and move the mouse over the viewport.
- Middle-click the tune/lock icon in the omnibox.
- Observe that the current tab navigates to
file:///etc/passwd. - Alternatively, middle-click the
+(New Tab) button and observe the same URL opening in a new tab.
Suggested Fix
To address this issue, writing to the PRIMARY clipboard buffer (ui::ClipboardBuffer::kSelection) via the TextSelectionChanged IPC should be restricted.
- Browser-Side Enforcement:
RenderWidgetHostViewAura::OnTextSelectionChangedshould verify that the frame currently has transient user activation before writing to the clipboard. - Renderer-Side Enforcement: Alternatively, the renderer (
RenderFrameImpl::DidChangeSelectionorSetSelectedText) should require transient user activation (not justHandlingInputEvent(), which is true for non-activating events likemousemove) before sending theTextSelectionChangedIPC to update the global clipboard.
Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f
Results 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.