CVE-2026-3938
Overview
Background
- `ClipboardHostImpl`
- The browser-process implementation of the
blink::mojom::ClipboardHostMojo interface, which renderers call to read and write the system clipboard on behalf of aRenderFrameHost. - `IsRendererPasteAllowed`
- The browser-side gate that existing clipboard read methods call with
ui::ClipboardBuffer::kCopyPasteandrender_frame_host()to decide whether this frame may read clipboard contents. It consults the embedder’sContentBrowserClient::IsClipboardPasteAllowed, which in practice checks for user activation or permission. - Web custom formats
- Clipboard data written by web pages under
"web "-prefixed MIME types (e.g.web text/custom) through the Async Clipboard API’s unsanitized custom-format support, stored as platform custom types and listed viaReadAvailableStandardAndCustomFormatNames. - Compromised renderer
- A renderer process under attacker control that can send arbitrary Mojo messages and so bypass any checks Blink normally performs before calling
ClipboardHost.
Root Cause Analysis
Before the patch, ClipboardHostImpl::ReadAvailableCustomAndStandardFormats and ClipboardHostImpl::ReadUnsanitizedCustomFormat read from ui::Clipboard::GetForCurrentThread() and returned the format list or raw custom-format bytes without calling IsRendererPasteAllowed. Per the commit message, the other clipboard read methods already perform that check. Any user-activation or paste-permission requirement for these two methods was therefore enforced only inside the renderer. A compromised renderer could call them directly and read clipboard format names and web custom-format data “without user activation”, breaking the invariant that the browser process authorizes every clipboard read.
The fix tightens both methods by adding an IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste, render_frame_host()) check as the first statement. On denial, the callback runs with an empty std::vector<std::u16string> or empty mojo_base::BigBuffer and the method returns before any clipboard read. As a separate hygiene change, the three existing early-return paths in ReadUnsanitizedCustomFormat (format name too long, unknown custom format, data too large) previously returned without running the Mojo reply callback. They now run it with an empty BigBuffer, so the reply is never dropped.
ClipboardHost read endpoints trusted the renderer to have enforced paste permission or user activation and performed no browser-side check. The fix applies the same IsRendererPasteAllowed gate the other clipboard reads use, before any data is fetched.Attack Path
- Compromise the renderer The attacker first gains control of a renderer process through a separate bug (precondition stated in the commit message).
- Call the Mojo methods directly
The compromised renderer calls
ReadAvailableCustomAndStandardFormatsandReadUnsanitizedCustomFormaton itsClipboardHostremote without any user activation, skipping Blink-side gating. - Browser returns clipboard data
The unpatched
ClipboardHostImpllists available standard and custom format names and returns the raw bytes of a requestedwebcustom format from the system clipboard. - Exfiltrate clipboard contents The attacker reads web custom-format clipboard data (and format names) the user copied elsewhere, for example from another site or app (inferred as to what sensitive data is present).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ClipboardPasteAllowedBrowserClientcontent/browser/renderer_host/clipboard_host_impl_unittest.cc |
modified | |
ClipboardHostImplTestcontent/browser/renderer_host/clipboard_host_impl_unittest.cc |
modified | |
ClipboardHostImplTestcontent/browser/renderer_host/clipboard_host_impl_unittest.cc |
modified | |
TEST_Fcontent/browser/renderer_host/clipboard_host_impl_unittest.cc |
modified |
Files Changed
content/browser/renderer_host/clipboard_host_impl.cccontent/browser/renderer_host/clipboard_host_impl_unittest.cc
Audit Directions
- `ClipboardHost` method parityEnumerate every read method on
blink::mojom::ClipboardHost(and platform-specific or newer additions such as format-availability queries, file reads, sequence-number reads) and confirm each callsIsRendererPasteAllowedor an equivalent browser-side check before touchingui::Clipboard. - Renderer-only gating of browser-process dataLook for other Mojo hosts (e.g.
FileSystemAccess,DataTransfer/drag-and-drop,RawClipboardHost-style interfaces) where user-activation or permission checks live only in Blink and the browser endpoint returns data unconditionally. - Dropped Mojo reply callbacks on early returnAudit Mojo handlers with several early
returns for paths that drop the response callback. Even when not directly exploitable, these hide logic errors and make it harder to reason about which branches release data.
Patch
From 67ed1f75de8df62e337bef6e7725a085ad045e36 Mon Sep 17 00:00:00 2001
From: Rohan Raja <roraja@microsoft.com>
Date: Sat, 24 Jan 2026 09:30:40 -0800
Subject: [PATCH] Add IsRendererPasteAllowed checks to clipboard custom format methods in ClipboardHostImpl
This CL fixes a security vulnerability where
ReadAvailableCustomAndStandardFormats() and
ReadUnsanitizedCustomFormat() bypass permission checks, allowing
compromised renderers to read web custom clipboard formats without user
activation.
Changes Made:
- Added IsRendererPasteAllowed() check to ReadAvailableCustomAndStandardFormats()
Returns empty vector if permission denied
- Added IsRendererPasteAllowed() check to ReadUnsanitizedCustomFormat()
Returns empty BigBuffer if permission denied
- Fixed all early return paths in ReadUnsanitizedCustomFormat() to properly invoke
callbacks, preventing callback leaks
Risk Assessment: Low
- Follows established pattern from existing
clipboard methods.
- No functional changes to legitimate use cases
requiring user activation
Bug: 474763968
Change-Id: I555360a1d3f2309f2a90b8da9709b014480606ca
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7509483
Reviewed-by: Sambamurthy Bandaru <sambamurthy.bandaru@microsoft.com>
Reviewed-by: Dan Clark <daniec@microsoft.com>
Reviewed-by: Shweta Bindal <shwetabindal@microsoft.com>
Commit-Queue: Rohan Raja <roraja@microsoft.com>
Cr-Commit-Position: refs/heads/main@{#1574177}
---
diff --git a/content/browser/renderer_host/clipboard_host_impl.cc b/content/browser/renderer_host/clipboard_host_impl.cc
index be6eb7b5..24f0fddc 100644
--- a/content/browser/renderer_host/clipboard_host_impl.cc
+++ b/content/browser/renderer_host/clipboard_host_impl.cc
@@ -605,6 +605,12 @@
void ClipboardHostImpl::ReadAvailableCustomAndStandardFormats(
ReadAvailableCustomAndStandardFormatsCallback callback) {
+ if (!IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
+ render_frame_host())) {
+ std::move(callback).Run(std::vector<std::u16string>());
+ return;
+ }
+
std::vector<std::u16string> format_types =
ui::Clipboard::GetForCurrentThread()
->ReadAvailableStandardAndCustomFormatNames(
@@ -615,10 +621,18 @@
void ClipboardHostImpl::ReadUnsanitizedCustomFormat(
const std::u16string& format,
ReadUnsanitizedCustomFormatCallback callback) {
+ if (!IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
+ render_frame_host())) {
+ std::move(callback).Run(mojo_base::BigBuffer());
+ return;
+ }
+
// `kMaxFormatSize` includes the null terminator as well so we check if
// the `format` size is strictly less than `kMaxFormatSize` or not.
- if (format.length() >= blink::mojom::ClipboardHost::kMaxFormatSize)
+ if (format.length() >= blink::mojom::ClipboardHost::kMaxFormatSize) {
+ std::move(callback).Run(mojo_base::BigBuffer());
return;
+ }
// Extract the custom format names and then query the web custom format
// corresponding to the MIME type.
@@ -630,15 +644,19 @@
std::string web_custom_format_string;
if (custom_format_names.find(format_name) != custom_format_names.end())
web_custom_format_string = custom_format_names[format_name];
- if (web_custom_format_string.empty())
+ if (web_custom_format_string.empty()) {
+ std::move(callback).Run(mojo_base::BigBuffer());
return;
+ }
std::string result;
ui::Clipboard::GetForCurrentThread()->ReadData(
ui::ClipboardFormatType::CustomPlatformType(web_custom_format_string),
data_endpoint.get(), &result);
- if (result.size() >= blink::mojom::ClipboardHost::kMaxDataSize)
+ if (result.size() >= blink::mojom::ClipboardHost::kMaxDataSize) {
+ std::move(callback).Run(mojo_base::BigBuffer());
return;
+ }
base::span<const uint8_t> span = base::as_byte_span(result);
mojo_base::BigBuffer buffer = mojo_base::BigBuffer(span);
std::move(callback).Run(std::move(buffer));
diff --git a/content/browser/renderer_host/clipboard_host_impl_unittest.cc b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
index f6eda8e..5b12e63 100644
--- a/content/browser/renderer_host/clipboard_host_impl_unittest.cc
+++ b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
@@ -24,6 +24,8 @@
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/navigation_simulator.h"
#include "content/public/test/test_renderer_host.h"
+#include "content/public/test/test_utils.h"
+#include "content/test/test_content_browser_client.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "mojo/public/cpp/system/message_pipe.h"
#include "skia/ext/skia_utils_base.h"
@@ -51,6 +53,26 @@
}
namespace content {
+// Custom ContentBrowserClient for testing clipboard paste permissions.
+class ClipboardPasteAllowedBrowserClient : public TestContentBrowserClient {
+ public:
+ ClipboardPasteAllowedBrowserClient() = default;
+ ~ClipboardPasteAllowedBrowserClient() override = default;
+
+ void set_is_clipboard_paste_allowed(bool allowed) {
+ is_clipboard_paste_allowed_ = allowed;
+ }
+
+ // ContentBrowserClient:
+ bool IsClipboardPasteAllowed(
+ content::RenderFrameHost* render_frame_host) override {
+ return is_clipboard_paste_allowed_;
+ }
+
+ private:
+ bool is_clipboard_paste_allowed_ = true;
+};
+
class ClipboardHostImplTest : public RenderViewHostTestHarness {
protected:
ClipboardHostImplTest() { ui::TestClipboard::CreateForCurrentThread(); }
@@ -920,4 +942,102 @@
base::RunLoop().RunUntilIdle();
}
+TEST_F(ClipboardHostImplTest,
+ ReadUnsanitizedCustomFormat_WithoutUserActivation) {
+ // Setup: Custom browser client that denies clipboard paste
+ ClipboardPasteAllowedBrowserClient browser_client;
+ browser_client.set_is_clipboard_paste_allowed(false);
+ ScopedContentBrowserClientSetting browser_client_setting(&browser_client);
+
+ // Write custom format to clipboard
+ std::string test_data = "confidential_custom_data";
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteData(u"web text/custom",
+ mojo_base::BigBuffer(base::as_byte_span(test_data)));
+ }
+
+ // Test: Try to read custom format without user activation
+ base::test::TestFuture<mojo_base::BigBuffer> future;
+ mojo_clipboard()->ReadUnsanitizedCustomFormat(u"web text/custom",
+ future.GetCallback());
+
+ // Verify: Should return empty buffer due to permission check failure
+ EXPECT_EQ(0u, future.Get().size());
+}
+
+TEST_F(ClipboardHostImplTest,
+ ReadAvailableCustomAndStandardFormats_WithUserActivation) {
+ // Setup: Custom browser client that allows clipboard paste
+ ClipboardPasteAllowedBrowserClient browser_client;
+ browser_client.set_is_clipboard_paste_allowed(true);
+ ScopedContentBrowserClientSetting browser_client_setting(&browser_client);
+
+ // Write some standard format data that TestClipboard can handle
+ mojo_clipboard()->WriteText(u"test text");
+ mojo_clipboard()->CommitWrite();
+ base::RunLoop().RunUntilIdle();
+
+ // Test: Read available formats with permission allowed
+ base::test::TestFuture<const std::vector<std::u16string>&> future;
+ mojo_clipboard()->ReadAvailableCustomAndStandardFormats(future.GetCallback());
+
+ // Verify: With permission allowed, the call completes successfully.
+ // TestClipboard should return standard formats like "text/plain".
+ const auto& formats = future.Get();
+ EXPECT_TRUE(std::ranges::contains(formats, u"text/plain"));
+}
+
+TEST_F(ClipboardHostImplTest, ReadUnsanitizedCustomFormat_WithUserActivation) {
+ // Setup: Custom browser client that allows clipboard paste
+ ClipboardPasteAllowedBrowserClient browser_client;
+ browser_client.set_is_clipboard_paste_allowed(true);
+ ScopedContentBrowserClientSetting browser_client_setting(&browser_client);
+
+ // Write custom format data using ScopedClipboardWriter which properly
+ // handles web custom format metadata
+ std::string test_data = "test_custom_data";
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteData(u"text/custom",
+ mojo_base::BigBuffer(base::as_byte_span(test_data)));
+ }
Regression Test / PoC
diff --git a/content/browser/renderer_host/clipboard_host_impl_unittest.cc b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
index f6eda8e..5b12e63 100644
--- a/content/browser/renderer_host/clipboard_host_impl_unittest.cc
+++ b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
@@ -24,6 +24,8 @@
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/navigation_simulator.h"
#include "content/public/test/test_renderer_host.h"
+#include "content/public/test/test_utils.h"
+#include "content/test/test_content_browser_client.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "mojo/public/cpp/system/message_pipe.h"
#include "skia/ext/skia_utils_base.h"
@@ -51,6 +53,26 @@
}
namespace content {
+// Custom ContentBrowserClient for testing clipboard paste permissions.
+class ClipboardPasteAllowedBrowserClient : public TestContentBrowserClient {
+ public:
+ ClipboardPasteAllowedBrowserClient() = default;
+ ~ClipboardPasteAllowedBrowserClient() override = default;
+
+ void set_is_clipboard_paste_allowed(bool allowed) {
+ is_clipboard_paste_allowed_ = allowed;
+ }
+
+ // ContentBrowserClient:
+ bool IsClipboardPasteAllowed(
+ content::RenderFrameHost* render_frame_host) override {
+ return is_clipboard_paste_allowed_;
+ }
+
+ private:
+ bool is_clipboard_paste_allowed_ = true;
+};
+
class ClipboardHostImplTest : public RenderViewHostTestHarness {
protected:
ClipboardHostImplTest() { ui::TestClipboard::CreateForCurrentThread(); }
@@ -920,4 +942,102 @@
base::RunLoop().RunUntilIdle();
}
+TEST_F(ClipboardHostImplTest,
+ ReadUnsanitizedCustomFormat_WithoutUserActivation) {
+ // Setup: Custom browser client that denies clipboard paste
+ ClipboardPasteAllowedBrowserClient browser_client;
+ browser_client.set_is_clipboard_paste_allowed(false);
+ ScopedContentBrowserClientSetting browser_client_setting(&browser_client);
+
+ // Write custom format to clipboard
+ std::string test_data = "confidential_custom_data";
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteData(u"web text/custom",
+ mojo_base::BigBuffer(base::as_byte_span(test_data)));
+ }
+
+ // Test: Try to read custom format without user activation
+ base::test::TestFuture<mojo_base::BigBuffer> future;
+ mojo_clipboard()->ReadUnsanitizedCustomFormat(u"web text/custom",
+ future.GetCallback());
+
+ // Verify: Should return empty buffer due to permission check failure
+ EXPECT_EQ(0u, future.Get().size());
+}
+
+TEST_F(ClipboardHostImplTest,
+ ReadAvailableCustomAndStandardFormats_WithUserActivation) {
+ // Setup: Custom browser client that allows clipboard paste
+ ClipboardPasteAllowedBrowserClient browser_client;
+ browser_client.set_is_clipboard_paste_allowed(true);
+ ScopedContentBrowserClientSetting browser_client_setting(&browser_client);
+
+ // Write some standard format data that TestClipboard can handle
+ mojo_clipboard()->WriteText(u"test text");
+ mojo_clipboard()->CommitWrite();
+ base::RunLoop().RunUntilIdle();
+
+ // Test: Read available formats with permission allowed
+ base::test::TestFuture<const std::vector<std::u16string>&> future;
+ mojo_clipboard()->ReadAvailableCustomAndStandardFormats(future.GetCallback());
+
+ // Verify: With permission allowed, the call completes successfully.
+ // TestClipboard should return standard formats like "text/plain".
+ const auto& formats = future.Get();
+ EXPECT_TRUE(std::ranges::contains(formats, u"text/plain"));
+}
+
+TEST_F(ClipboardHostImplTest, ReadUnsanitizedCustomFormat_WithUserActivation) {
+ // Setup: Custom browser client that allows clipboard paste
+ ClipboardPasteAllowedBrowserClient browser_client;
+ browser_client.set_is_clipboard_paste_allowed(true);
+ ScopedContentBrowserClientSetting browser_client_setting(&browser_client);
+
+ // Write custom format data using ScopedClipboardWriter which properly
+ // handles web custom format metadata
+ std::string test_data = "test_custom_data";
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteData(u"text/custom",
+ mojo_base::BigBuffer(base::as_byte_span(test_data)));
+ }
+
+ // Test: Read custom format with permission allowed
+ // Note: Need to prepend "web " prefix to match how ExtractCustomPlatformNames
+ // works
+ base::test::TestFuture<mojo_base::BigBuffer> future;
+ mojo_clipboard()->ReadUnsanitizedCustomFormat(u"web text/custom",
+ future.GetCallback());
+
+ // Verify: With permission allowed, the data should be successfully retrieved
+ const auto& result = future.Get();
+ EXPECT_GT(result.size(), 0u);
+
+ // Verify the content matches what was written
+ std::string retrieved_data(result.begin(), result.end());
+ EXPECT_EQ(retrieved_data, test_data);
+}
+
+TEST_F(ClipboardHostImplTest,
+ ReadAvailableCustomAndStandardFormats_TextWithoutUserActivation) {
+ // Setup: Custom browser client that denies clipboard paste
+ ClipboardPasteAllowedBrowserClient browser_client;
+ browser_client.set_is_clipboard_paste_allowed(false);
+ ScopedContentBrowserClientSetting browser_client_setting(&browser_client);
+
+ // Write standard text format to clipboard
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteText(u"test text");
+ }
+
+ // Test: Try to read available formats without permission
+ base::test::TestFuture<const std::vector<std::u16string>&> future;
+ mojo_clipboard()->ReadAvailableCustomAndStandardFormats(future.GetCallback());
+
+ // Verify: Should return empty vector due to permission check failure
+ EXPECT_EQ(0u, future.Get().size());
+}
+
} // namespace content
Original Bug Report
ClipboardHost custom format methods bypass IsClipboardPasteAllowed (Issue 40051481 incomplete)
Steps to reproduce the problem
STEPS TO REPRODUCE:
-
Build Chromium content_shell
-
Start HTTP server from the Release directory: cd [chromium_src]/out/Release python -m http.server 8888
-
Copy the attached PoC files (poc.html, victim.html) to the Release directory
-
Open victim.html in NORMAL Chrome browser: http://localhost:8888/victim.html Click “Copy Sensitive Data” to put test data on clipboard
-
Launch content_shell with MojoJS (simulates compromised renderer): .\content_shell.exe –enable-blink-features=MojoJS,MojoJSTest http://localhost:8888/poc.html
VULNERABILITY: In content/browser/renderer_host/clipboard_host_impl.cc:
- ReadAvailableCustomAndStandardFormats() (line 607)
- ReadUnsanitizedCustomFormat() (line 616)
These methods do not call IsRendererPasteAllowed() before accessing clipboard.
Other Read methods (ReadText, ReadHtml, ReadImage, etc.) do have this check.
Related: Issue 40051481 addressed similar clipboard permission bypass.
Problem Description
In content/browser/renderer_host/clipboard_host_impl.cc, two methods access clipboard data without calling IsRendererPasteAllowed():
ReadAvailableCustomAndStandardFormats() (line 607) ReadUnsanitizedCustomFormat() (line 616) Other Read methods in the same file do call IsRendererPasteAllowed() before accessing clipboard data:
ReadText() ReadHtml() ReadSvg() ReadRtf() ReadPng() ReadFiles() ReadDataTransferCustomData() Historical Context:
The custom format methods were added in September 2021 (commit 8aa9dc97 - “[Clipboard API] Clipboard Custom Formats implementation Part 7”). Issue 40051481 was fixed in October 2022, which added IsRendererPasteAllowed() checks to clipboard read methods. However, ReadAvailableCustomAndStandardFormats() and ReadUnsanitizedCustomFormat() were not updated as part of that fix.
Affected Code: void ClipboardHostImpl::ReadAvailableCustomAndStandardFormats( ReadAvailableCustomAndStandardFormatsCallback callback) { // No IsRendererPasteAllowed() check std::vectorstd::u16string format_types = ui::Clipboard::GetForCurrentThread() ->ReadAvailableStandardAndCustomFormatNames(…); std::move(callback).Run(std::move(format_types)); }
void ClipboardHostImpl::ReadUnsanitizedCustomFormat( const std::u16string& format, ReadUnsanitizedCustomFormatCallback callback) { // No IsRendererPasteAllowed() check // … reads clipboard data directly }
Additional Comments
These methods were added in September 2021 (commit 8aa9dc97) without IsRendererPasteAllowed() checks. When Issue 40051481 was fixed in October 2022, the permission check was added to other clipboard read methods but these two custom format methods were not included. This is an incomplete fix of Issue 40051481 rather than a regression.
The W3C Clipboard API spec Section 7.3.1 requires read() to run “check clipboard read permission” (Section 9.1.1) before accessing clipboard data. If the check fails, the operation must reject with NotAllowedError. The vulnerable methods bypass this requirement.
Reference: https://w3c.github.io/clipboard-apis/#dom-clipboard-read
Summary
ClipboardHost custom format methods bypass IsClipboardPasteAllowed (Issue 40051481 incomplete)
Custom Questions
Reporter credit:
vicevirus
Additional Data
Category: Security
Chrome Channel: Not sure
Regression: No \