Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in WebProtect
DescriptionInappropriate implementation in WebProtect
ComponentWebProtect
Bug ClassLogic Error
Tracker511765328
Fix commit4e8515bc1597 (chromium/src) +75/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc
modified
TEST_F
chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc
modified

Files Changed

  • chrome/browser/enterprise/data_protection/paste_allowed_request.cc
  • chrome/browser/enterprise/data_protection/paste_allowed_request_unittest.cc
  • content/public/browser/clipboard_types.cc
  • content/public/browser/clipboard_types.h
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.
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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.cc
  • chrome/browser/chrome_content_browser_client.cc
  • content/public/browser/clipboard_types.cc
  • chrome/browser/enterprise/data_protection/data_protection_clipboard_utils.cc
  • content/browser/renderer_host/clipboard_host_impl.cc
  • chrome/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

  1. Improper Cache Keying in PasteAllowedRequest: In chrome/browser/enterprise/data_protection/paste_allowed_request.cc, StartPasteAllowedRequest caches requests using the GlobalId of the PrimaryMainFrame rather than the RenderFrameHost initiating the request. This causes requests from cross-origin subframes to be improperly coalesced with requests from the main frame.
  2. Broad Interaction Check: ChromeContentBrowserClient::IsClipboardPasteAllowed validates clipboard access by checking WebContents::HasRecentInteraction(). Because this check is scoped to the entire WebContents, an interaction in the main frame grants a 5-second window where any subframe can bypass this check via direct Mojo IPCs.
  3. 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.

  1. Setup: An attacker compromises the renderer process of a cross-origin iframe embedded in a trusted main page.
  2. 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.
  3. User Action: The user focuses the trusted main frame and presses Ctrl+V. This records an interaction on the WebContents.
  4. Dialog Trigger: The paste violates a “Warn” DLP rule, suspending the paste and showing a warning dialog to the user.
  5. Exploitation: While the dialog is visible, the compromised subframe sends a ClipboardHost.ReadText Mojo IPC directly to the browser process.
  6. Bypass: The request passes the HasRecentInteraction() check due to the user’s recent Ctrl+V in the main frame. The request is attributed to the main frame’s URL and coalesced into the main frame’s pending PasteAllowedRequest.
  7. 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 the RequestsMapStorage using the GlobalId of the specific RenderFrameHost making the request, not the primary main frame.
  • CreateDataEndpoint: Construct the DataTransferEndpoint using the URL of the specific RenderFrameHost, not its main frame.
  • ChromeContentBrowserClient::IsClipboardPasteAllowed: Consider restricting the interaction check to the specific RenderFrameHost (e.g., requiring transient user activation on the frame itself) rather than relying on WebContents::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

View on issue tracker