CVE-2026-17765
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc |
modified | |
TEST_Fchrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc |
modified |
Files Changed
chrome/browser/enterprise/data_protection/paste_allowed_request.ccchrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cccontent/public/browser/clipboard_types.cccontent/public/browser/clipboard_types.h
Patch
From 4e8515bc1597b095546b705dfdff36bbc3f99657 Mon Sep 17 00:00:00 2001 From: Dominique Fauteux-Chapleau <[email protected]> Date: Thu, 25 Jun 2026 12:01:22 -0700 Subject: [PATCH] Key PasteAllowedRequest cache by initiating frame PasteAllowedRequest::StartPasteAllowedRequest keyed its cache by the destination tab's primary main frame, so clipboard paste requests from distinct frames within the same page were coalesced into a single entry and resolved together. A child frame's read could therefore be appended to a pending main-frame request and receive that request's result (including replaced data restored after a Warn-level Data Controls dialog) instead of being evaluated on its own. ClipboardEndpoint now retains the initiating RenderFrameHost via its GlobalRenderFrameHostId and exposes it through a new render_frame_host() accessor alongside the existing web_contents() and browser_context() ones. PasteAllowedRequest keys its cache by the initiating frame so each frame's paste is evaluated and resolved independently. Fixed: 511765328 Change-Id: Ic202ac4f2cb8978b98752b1472113983d835dece Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7983191 Reviewed-by: Avi Drissman <[email protected]> Reviewed-by: Lina Ismail <[email protected]> Commit-Queue: Dominique Fauteux-Chapleau <[email protected]> Cr-Commit-Position: refs/heads/main@{#1652598} --- diff --git a/chrome/browser/enterprise/data_protection/paste_allowed_request.cc b/chrome/browser/enterprise/data_protection/paste_allowed_request.cc index 156e3784..4314a39 100644 --- a/chrome/browser/enterprise/data_protection/paste_allowed_request.cc +++ b/chrome/browser/enterprise/data_protection/paste_allowed_request.cc @@ -56,8 +56,11 @@ CleanupObsoleteRequests(); ui::ClipboardSequenceNumberToken seqno = metadata.seqno; + content::RenderFrameHost* destination_rfh = destination.render_frame_host(); content::GlobalRenderFrameHostId rfh_id = - destination.web_contents()->GetPrimaryMainFrame()->GetGlobalId(); + destination_rfh + ? destination_rfh->GetGlobalId() + : destination.web_contents()->GetPrimaryMainFrame()->GetGlobalId(); // Add |callback| to the callbacks associated to the sequence number, adding // an entry to the map if one does not exist. diff --git a/chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc b/chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc index c143aca..002ef12f 100644 --- a/chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc +++ b/chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc @@ -27,6 +27,7 @@ #include "content/public/browser/web_contents.h" #include "content/public/test/browser_task_environment.h" #include "content/public/test/navigation_simulator.h" +#include "content/public/test/test_renderer_host.h" #include "testing/gtest/include/gtest/gtest.h" #include "ui/base/clipboard/clipboard_monitor.h" #include "ui/base/clipboard/scoped_clipboard_writer.h" @@ -158,6 +159,25 @@ main_rfh()); } + content::RenderFrameHost& child_rfh() { + if (!child_rfh_) { + content::RenderFrameHostTester::For(&main_rfh()) + ->InitializeRenderFrameIfNeeded(); + child_rfh_ = content::RenderFrameHostTester::For(&main_rfh()) + ->AppendChild("child"); + } + return *child_rfh_; + } + + content::ClipboardEndpoint child_endpoint() { + return content::ClipboardEndpoint( + ui::DataTransferEndpoint(GURL("https://google.com")), + base::BindLambdaForTesting([this]() -> content::BrowserContext* { + return static_cast<content::BrowserContext*>(profile_); + }), + child_rfh()); + } + content::WebContents* secondary_web_contents() { if (!secondary_web_contents_) { content::WebContents::CreateParams params(profile_); @@ -182,10 +202,12 @@ protected: content::BrowserTaskEnvironment task_environment_{ base::test::TaskEnvironment::TimeSource::MOCK_TIME}; + content::RenderViewHostTestEnabler rvh_test_enabler_; TestingProfileManager profile_manager_; raw_ptr<TestingProfile> profile_; std::unique_ptr<content::WebContents> main_web_contents_; std::unique_ptr<content::WebContents> secondary_web_contents_; + raw_ptr<content::RenderFrameHost> child_rfh_ = nullptr; #if BUILDFLAG(IS_CHROMEOS) std::unique_ptr<ash::network_config::CrosNetworkConfigTestHelper> network_config_helper_; @@ -429,6 +451,39 @@ EXPECT_EQ(1u, PasteAllowedRequest::requests_count_for_testing()); } +TEST_F(PasteAllowedRequestTest, ChildFrameDestinationCreatesSeparateRequest) { + auto seqno = ui::Clipboard::GetForCurrentThread()->GetSequenceNumber( + ui::ClipboardBuffer::kCopyPaste); + + // Seed a completed request for the main frame so that a paste targeting it + // would resolve to `kCachedText`. + const std::u16string kCachedText = u"cached"; + content::ClipboardPasteData cached_data; + cached_data.text = kCachedText; + PasteAllowedRequest cached_request; + cached_request.Complete(cached_data); + PasteAllowedRequest::AddRequestToCacheForTesting( + main_rfh().GetGlobalId(), seqno, std::move(cached_request)); + EXPECT_EQ(1u, PasteAllowedRequest::requests_count_for_testing()); + + // A paste into a child frame of the same page must not be coalesced into the + // main frame's request, so it should be evaluated independently and resolve + // to its own data rather than the main frame's cached result. + const std::u16string kChildText = u"child"; + content::ClipboardPasteData child_data; + child_data.text = kChildText; + + base::test::TestFuture<std::optional<content::ClipboardPasteData>> future; + PasteAllowedRequest::StartPasteAllowedRequest( + /*source*/ secondary_endpoint(), /*destination*/ child_endpoint(), + {.seqno = seqno}, child_data, future.GetCallback()); + + ASSERT_TRUE(future.Get()); + EXPECT_EQ(future.Get()->text, kChildText); + + EXPECT_EQ(2u, PasteAllowedRequest::requests_count_for_testing()); +} + TEST_F(PasteAllowedRequestTest, UnknownSource) { auto seqno = ui::Clipboard::GetForCurrentThread()->GetSequenceNumber( ui::ClipboardBuffer::kCopyPaste); diff --git a/content/public/browser/clipboard_types.cc b/content/public/browser/clipboard_types.cc index bee3a745..b1cae56 100644 --- a/content/public/browser/clipboard_types.cc +++ b/content/public/browser/clipboard_types.cc @@ -95,7 +95,8 @@ RenderFrameHost& rfh) : data_transfer_endpoint_(data_transfer_endpoint.CopyAsOptional()), browser_context_fetcher_(std::move(browser_context_fetcher)), - web_contents_(WebContents::FromRenderFrameHost(&rfh)->GetWeakPtr()) {} + web_contents_(WebContents::FromRenderFrameHost(&rfh)->GetWeakPtr()), + render_frame_host_id_(rfh.GetGlobalId()) {} ClipboardEndpoint::ClipboardEndpoint(const ClipboardEndpoint&) = default; ClipboardEndpoint& ClipboardEndpoint::operator=(const ClipboardEndpoint&) = @@ -113,6 +114,10 @@ return web_contents_.get(); } +RenderFrameHost* ClipboardEndpoint::render_frame_host() const { + return RenderFrameHost::FromID(render_frame_host_id_); +} + const ui::ClipboardFormatType& SourceRFHTokenType() { #if BUILDFLAG(IS_APPLE) constexpr char kTypeName[] = "org.chromium.internal.source-rfh-token"; diff --git a/content/public/browser/clipboard_types.h b/content/public/browser/clipboard_types.h index 1a87ecdb..34b8bc4 100644 --- a/content/public/browser/clipboard_types.h +++ b/content/public/browser/clipboard_types.h @@ -10,6 +10,7 @@ #include "base/memory/weak_ptr.h" #include "base/types/optional_ref.h" #include "content/common/content_export.h" +#include "content/public/browser/global_routing_id.h" #include "third_party/skia/include/core/SkBitmap.h" #include "ui/base/clipboard/clipboard_buffer.h" #include "ui/base/clipboard/clipboard_format_type.h" @@ -122,6 +123,11 @@ // the tab has been closed. WebContents* web_contents() const; + // RenderFrameHost that initiated the clipboard interaction when it + // corresponds to a browser tab. This can be null if the endpoint is not a + // Chrome tab, or if the frame has since been destroyed. + RenderFrameHost* render_frame_host() const; + private: // The `ui::DataTransferEndpoint` corresponding to the clipboard interaction. // An empty value represents a copy from Chrome's omnibox, a copy from a @@ -136,6 +142,10 @@ // null if the endpoint has no associated WebContents, or if it's been closed. base::WeakPtr<WebContents> web_contents_; + + // ID of the RenderFrameHost that initiated the clipboard interaction. + // Defaults to an invalid ID when the endpoint has no associated frame. + GlobalRenderFrameHostId render_frame_host_id_; }; // Chromium-only type to associate clipboard data to the RFH it originated from.
Regression Test / PoC
diff --git a/chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc b/chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc
index c143aca..002ef12f 100644
--- a/chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc
+++ b/chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc
@@ -27,6 +27,7 @@
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/navigation_simulator.h"
+#include "content/public/test/test_renderer_host.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/clipboard/clipboard_monitor.h"
#include "ui/base/clipboard/scoped_clipboard_writer.h"
@@ -158,6 +159,25 @@
main_rfh());
}
+ content::RenderFrameHost& child_rfh() {
+ if (!child_rfh_) {
+ content::RenderFrameHostTester::For(&main_rfh())
+ ->InitializeRenderFrameIfNeeded();
+ child_rfh_ = content::RenderFrameHostTester::For(&main_rfh())
+ ->AppendChild("child");
+ }
+ return *child_rfh_;
+ }
+
+ content::ClipboardEndpoint child_endpoint() {
+ return content::ClipboardEndpoint(
+ ui::DataTransferEndpoint(GURL("https://google.com")),
+ base::BindLambdaForTesting([this]() -> content::BrowserContext* {
+ return static_cast<content::BrowserContext*>(profile_);
+ }),
+ child_rfh());
+ }
+
content::WebContents* secondary_web_contents() {
if (!secondary_web_contents_) {
content::WebContents::CreateParams params(profile_);
@@ -182,10 +202,12 @@
protected:
content::BrowserTaskEnvironment task_environment_{
base::test::TaskEnvironment::TimeSource::MOCK_TIME};
+ content::RenderViewHostTestEnabler rvh_test_enabler_;
TestingProfileManager profile_manager_;
raw_ptr<TestingProfile> profile_;
std::unique_ptr<content::WebContents> main_web_contents_;
std::unique_ptr<content::WebContents> secondary_web_contents_;
+ raw_ptr<content::RenderFrameHost> child_rfh_ = nullptr;
#if BUILDFLAG(IS_CHROMEOS)
std::unique_ptr<ash::network_config::CrosNetworkConfigTestHelper>
network_config_helper_;
@@ -429,6 +451,39 @@
EXPECT_EQ(1u, PasteAllowedRequest::requests_count_for_testing());
}
+TEST_F(PasteAllowedRequestTest, ChildFrameDestinationCreatesSeparateRequest) {
+ auto seqno = ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+ ui::ClipboardBuffer::kCopyPaste);
+
+ // Seed a completed request for the main frame so that a paste targeting it
+ // would resolve to `kCachedText`.
+ const std::u16string kCachedText = u"cached";
+ content::ClipboardPasteData cached_data;
+ cached_data.text = kCachedText;
+ PasteAllowedRequest cached_request;
+ cached_request.Complete(cached_data);
+ PasteAllowedRequest::AddRequestToCacheForTesting(
+ main_rfh().GetGlobalId(), seqno, std::move(cached_request));
+ EXPECT_EQ(1u, PasteAllowedRequest::requests_count_for_testing());
+
+ // A paste into a child frame of the same page must not be coalesced into the
+ // main frame's request, so it should be evaluated independently and resolve
+ // to its own data rather than the main frame's cached result.
+ const std::u16string kChildText = u"child";
+ content::ClipboardPasteData child_data;
+ child_data.text = kChildText;
+
+ base::test::TestFuture<std::optional<content::ClipboardPasteData>> future;
+ PasteAllowedRequest::StartPasteAllowedRequest(
+ /*source*/ secondary_endpoint(), /*destination*/ child_endpoint(),
+ {.seqno = seqno}, child_data, future.GetCallback());
+
+ ASSERT_TRUE(future.Get());
+ EXPECT_EQ(future.Get()->text, kChildText);
+
+ EXPECT_EQ(2u, PasteAllowedRequest::requests_count_for_testing());
+}
+
TEST_F(PasteAllowedRequestTest, UnknownSource) {
auto seqno = ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
ui::ClipboardBuffer::kCopyPaste);
Original Bug Report
Potential cross-origin clipboard data leak via DLP request coalescing
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: A compromised cross-origin subframe can potentially steal Enterprise Data Protection (DLP) protected clipboard data. This occurs due to improper request coalescing, where a subframe’s clipboard read request is incorrectly merged with a main frame’s pending paste request, bypassing frame-specific interaction and attribution checks.
Affected files:
chrome/browser/enterprise/data_protection/paste_allowed_request.ccchrome/browser/chrome_content_browser_client.cccontent/public/browser/clipboard_types.ccchrome/browser/enterprise/data_protection/data_protection_clipboard_utils.cccontent/browser/renderer_host/clipboard_host_impl.ccchrome/browser/enterprise/data_controls/desktop_data_controls_dialog.h
Estimated timestamp from git blame: 2026-04-16
Summary
A logic flaw in how Enterprise Data Protection evaluates and caches clipboard paste requests could allow a compromised cross-origin subframe to exfiltrate sensitive clipboard data. The vulnerability relies on three interconnected issues where frame-specific attribution is lost in the browser process.
Vulnerability Details
- Improper Cache Keying in
PasteAllowedRequest: Inchrome/browser/enterprise/data_protection/paste_allowed_request.cc,StartPasteAllowedRequestcaches requests using theGlobalIdof thePrimaryMainFramerather than theRenderFrameHostinitiating the request. This causes requests from cross-origin subframes to be improperly coalesced with requests from the main frame. - Broad Interaction Check:
ChromeContentBrowserClient::IsClipboardPasteAllowedvalidates clipboard access by checkingWebContents::HasRecentInteraction(). Because this check is scoped to the entireWebContents, an interaction in the main frame grants a 5-second window where any subframe can bypass this check via direct Mojo IPCs. - Incorrect Destination Attribution: When evaluating DLP policy,
CreateDataEndpoint(content/public/browser/clipboard_types.cc) constructs the destination endpoint using the main frame’s URL (rfh.GetMainFrame()->GetLastCommittedURL()), causing subframe requests to be incorrectly evaluated under the trusted main frame’s policy identity.
Potential Attack Scenario
Note: These are suggested steps; we have not verified them with a working proof-of-concept.
- Setup: An attacker compromises the renderer process of a cross-origin iframe embedded in a trusted main page.
- Precondition: The user copies sensitive data protected by an Enterprise Data Protection policy. The browser places a placeholder on the OS clipboard and stashes the original data in
LastReplacedClipboardData. - User Action: The user focuses the trusted main frame and presses
Ctrl+V. This records an interaction on theWebContents. - Dialog Trigger: The paste violates a “Warn” DLP rule, suspending the paste and showing a warning dialog to the user.
- Exploitation: While the dialog is visible, the compromised subframe sends a
ClipboardHost.ReadTextMojo IPC directly to the browser process. - Bypass: The request passes the
HasRecentInteraction()check due to the user’s recentCtrl+Vin the main frame. The request is attributed to the main frame’s URL and coalesced into the main frame’s pendingPasteAllowedRequest. - Exfiltration: The user clicks “Paste anyway” on the dialog. The DLP logic restores the sensitive data and calls
PasteAllowedRequest::InvokeCallbacks(), which broadcasts the protected data to all queued callbacks, including the attacker’s subframe.
Suggested Fixes
PasteAllowedRequest::StartPasteAllowedRequest: Key theRequestsMapStorageusing theGlobalIdof the specificRenderFrameHostmaking the request, not the primary main frame.CreateDataEndpoint: Construct theDataTransferEndpointusing the URL of the specificRenderFrameHost, not its main frame.ChromeContentBrowserClient::IsClipboardPasteAllowed: Consider restricting the interaction check to the specificRenderFrameHost(e.g., requiring transient user activation on the frame itself) rather than relying onWebContents::HasRecentInteraction().
(Secondary Finding: In chrome/browser/enterprise/data_controls/desktop_data_controls_dialog.h, the member scoped_ignore_input_events_ is declared but its implementation is missing/unused, suggesting input suppression during the dialog might be inactive.)
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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.
Raised in root component due to access or custom field issues on 1208119