Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in BFCache
DescriptionMissing authorization in BFCache
ComponentBFCache
Sub-componentClipboardHostImpl
Bug ClassAuthorization Error
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.
Tracker514529599
Fix commitd5674ed78a8c (chromium/src) +160/-11
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-19 (per vendor advisory)
Fix landed2026-07-08 (public git)
Disclosed2026-08-25
Patch gap48 days — fix public in source before the advisory

Background

`ClipboardHostImpl`
the browser-process implementation of blink::mojom::ClipboardHost, which serves a frame’s clipboard read, write and change-listener requests; it is a DocumentService tied to one RenderFrameHost.
Inactive document
a RenderFrameHost that is not currently shown to the user, for example one stored in the back/forward cache (BFCache) or pending deletion; its Mojo receivers can stay bound while it is inactive.
`IsInactiveAndDisallowActivation(reason)`
a RenderFrameHost check that returns true when the frame is inactive and also makes sure it is never reactivated (for example by evicting it from BFCache), recording reason for metrics.
`DisallowActivationReasonId::kClipboard`
a new reason value (47) added to identify clipboard IPCs that arrive for inactive frames.

Root Cause Analysis

ClipboardHostImpl handlers checked whether a paste was permitted (IsRendererPasteAllowed) but never checked the document’s lifecycle state. The receiver stays bound while the frame sits in BFCache, so IPCs from a document the user had navigated away from were still processed in full. Such a document could read or write the system clipboard and register a clipboardchange listener. According to the commit message, it could also benefit from the tab-wide recent-interaction state of the page currently in the foreground. In addition, OnClipboardDataChanged() kept notifying a registered listener even after its document became inactive.

The fix adds an IsInactiveAndDisallowActivation(DisallowActivationReasonId::kClipboard) guard at the start of every mojom::ClipboardHost entry point, including the macOS WriteStringToFindPboard. Reads now reply with empty or default values, writes and listener registration are dropped, and CommitWrite() resets any staged writer state. OnClipboardDataChanged() now returns early when !render_frame_host().IsActive(). Each guard runs before the existing permission check and before any clipboard access, so this is a pure tightening, and an offending page is also prevented from being restored from BFCache.

Key insight
The browser authorised clipboard IPCs by frame identity and paste policy only, ignoring whether the frame was still the active document. Adding a lifecycle check that also disallows activation to every entry point keeps background or cached documents from using clipboard privileges.

Attack Path

  1. Document becomes inactive a page with a bound ClipboardHost connection is navigated away from and stored in BFCache (the regression tests simulate this with LifecycleStateImpl::kInBackForwardCache).
  2. Clipboard IPC arrives after deactivation the renderer sends ReadText, WriteText/CommitWrite, RegisterClipboardListener or a similar message. Because JavaScript in a BFCached page is frozen, this realistically needs a compromised renderer or a race with the lifecycle transition (inferred).
  3. Browser processes the request before the fix, ClipboardHostImpl read or changed the system clipboard for the inactive document, subject only to IsRendererPasteAllowed, which may count the foreground page’s recent interaction.
  4. Ongoing observation a listener registered earlier kept receiving OnClipboardDataChanged notifications while the user worked in another page.

Impact Assessment

The advisory describes this as missing authorization in BFCache and gives no in-the-wild status. An attacker who controls a renderer, or who wins a lifecycle race (inferred), could read or overwrite the user’s system clipboard and track clipboard changes from a document that is no longer visible, which is a privacy and integrity issue in the browser-process clipboard service. There is no memory corruption; the effect is information disclosure or tampering limited to clipboard contents.

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/clipboard_host_impl.cc
modified
if
content/browser/renderer_host/clipboard_host_impl_mac.mm
modified
TEST_F
content/browser/renderer_host/clipboard_host_impl_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/clipboard_host_impl.h
  • content/browser/renderer_host/clipboard_host_impl.cc
  • content/browser/renderer_host/clipboard_host_impl_mac.mm
  • tools/metrics/histograms/enums.xml
  • content/browser/renderer_host/clipboard_host_impl_unittest.cc
  • content/public/browser/disallow_activation_reason.h

Audit Directions

  • `DocumentService` handlers without lifecycle checks
    review other browser-side DocumentService and RenderFrameHost-scoped Mojo hosts that touch user-visible or system resources (for example file system access, permissions, sensors and window management) for missing IsActive() or IsInactiveAndDisallowActivation() guards.
  • Tab-wide state used for per-document authorisation
    look for permission checks that consult WebContents-level signals (recent interaction, focus, visibility) when the requesting document may not be the one in the foreground.
  • Persistent listeners and subscriptions
    find observer registrations, such as change listeners and event subscriptions, whose notifications continue after the registering frame enters BFCache or pending deletion.
From d5674ed78a8c68ca800217c5a3eda5ddfbbc8a2c Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <[email protected]>
Date: Wed, 08 Jul 2026 00:51:12 -0700
Subject: [PATCH] ClipboardHostImpl: ignore requests from inactive documents

ClipboardHostImpl is a DocumentService whose Mojo receiver stays bound
while its RenderFrameHost is inactive (e.g. stored in the back/forward
cache). The IPC handlers previously did not check the document
lifecycle, so a renderer could keep reading or writing the system
clipboard, register a clipboardchange listener, or piggy-back on the
tab-wide recent-interaction state of the foreground page after its
document had become inactive.

Add render_frame_host().IsActive() guards to every mojom::ClipboardHost
entry point (including the macOS find- pasteboard write) and to
OnClipboardDataChanged(). Read methods now reply with empty results,
write methods are dropped, and CommitWrite() discards any staged writer
state when the document is not active.

Bug: 514529599
Change-Id: Iad5186e86b5d5e9fb2342614700aac2178b275b0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8030502
Reviewed-by: Rakina Zata Amni <[email protected]>
Commit-Queue: Eriko Kurimoto <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1658557}
---
diff --git a/content/browser/renderer_host/clipboard_host_impl.cc b/content/browser/renderer_host/clipboard_host_impl.cc
index eecfccb8..dc1dd4f 100644
--- a/content/browser/renderer_host/clipboard_host_impl.cc
+++ b/content/browser/renderer_host/clipboard_host_impl.cc
@@ -117,12 +117,22 @@
 
 void ClipboardHostImpl::GetSequenceNumber(ui::ClipboardBuffer clipboard_buffer,
                                           GetSequenceNumberCallback callback) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    std::move(callback).Run(absl::uint128());
+    return;
+  }
   std::move(callback).Run(GetSequenceNumberImpl(clipboard_buffer));
 }
 
 void ClipboardHostImpl::ReadAvailableTypes(
     ui::ClipboardBuffer clipboard_buffer,
     ReadAvailableTypesCallback callback) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    std::move(callback).Run({});
+    return;
+  }
   auto* clipboard = ui::Clipboard::GetForCurrentThread();
   auto data_endpoint = CreateDataEndpoint(render_frame_host());
 
@@ -190,6 +200,11 @@
 void ClipboardHostImpl::IsFormatAvailable(blink::mojom::ClipboardFormat format,
                                           ui::ClipboardBuffer clipboard_buffer,
                                           IsFormatAvailableCallback callback) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    std::move(callback).Run(false);
+    return;
+  }
   ui::Clipboard* clipboard = ui::Clipboard::GetForCurrentThread();
   auto data_endpoint = CreateDataEndpoint(render_frame_host());
   clipboard->GetAllAvailableFormats(
@@ -230,7 +245,9 @@
 
 void ClipboardHostImpl::ReadText(ui::ClipboardBuffer clipboard_buffer,
                                  ReadTextCallback callback) {
-  if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
     std::move(callback).Run(std::u16string());
     return;
   }
@@ -264,7 +281,9 @@
 
 void ClipboardHostImpl::ReadHtml(ui::ClipboardBuffer clipboard_buffer,
                                  ReadHtmlCallback callback) {
-  if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
     std::move(callback).Run(std::u16string(), GURL(), 0, 0);
     return;
   }
@@ -305,7 +324,9 @@
 
 void ClipboardHostImpl::ReadSvg(ui::ClipboardBuffer clipboard_buffer,
                                 ReadSvgCallback callback) {
-  if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
     std::move(callback).Run(std::u16string());
     return;
   }
@@ -340,7 +361,9 @@
 
 void ClipboardHostImpl::ReadRtf(ui::ClipboardBuffer clipboard_buffer,
                                 ReadRtfCallback callback) {
-  if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
     std::move(callback).Run(std::string());
     return;
   }
@@ -376,7 +399,9 @@
 
 void ClipboardHostImpl::ReadPng(ui::ClipboardBuffer clipboard_buffer,
                                 ReadPngCallback callback) {
-  if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
     std::move(callback).Run(mojo_base::BigBuffer());
     return;
   }
@@ -425,7 +450,9 @@
 
 void ClipboardHostImpl::ReadFiles(ui::ClipboardBuffer clipboard_buffer,
                                   ReadFilesCallback callback) {
-  if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
     std::move(callback).Run(blink::mojom::ClipboardFiles::New());
     return;
   }
@@ -511,7 +538,9 @@
     ui::ClipboardBuffer clipboard_buffer,
     const std::u16string& type,
     ReadDataTransferCustomDataCallback callback) {
-  if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
     std::move(callback).Run(std::u16string());
     return;
   }
@@ -549,6 +578,10 @@
 }
 
 void ClipboardHostImpl::WriteText(const std::u16string& text) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   ClipboardPasteData data;
   data.text = text;
   ++pending_writes_;
@@ -565,6 +598,10 @@
 
 void ClipboardHostImpl::WriteHtml(const std::u16string& markup,
                                   const GURL& url) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   ClipboardPasteData data;
   data.html = markup;
   ++pending_writes_;
@@ -580,6 +617,10 @@
 }
 
 void ClipboardHostImpl::WriteSvg(const std::u16string& markup) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   ClipboardPasteData data;
   data.svg = markup;
   ++pending_writes_;
@@ -595,11 +636,19 @@
 }
 
 void ClipboardHostImpl::WriteSmartPasteMarker() {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   clipboard_writer_->WriteWebSmartPaste();
 }
 
 void ClipboardHostImpl::WriteDataTransferCustomData(
     const base::flat_map<std::u16string, std::u16string>& data) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   ClipboardPasteData clipboard_paste_data;
   clipboard_paste_data.custom_data = data;
 
@@ -624,11 +673,19 @@
 
 void ClipboardHostImpl::WriteBookmark(const std::string& url,
                                       const std::u16string& title) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   clipboard_writer_->WriteURL(
       ui::ClipboardUrlInfo{.url = GURL(url), .title = title});
 }
 
 void ClipboardHostImpl::WriteImage(const SkBitmap& bitmap) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   ClipboardPasteData data;
   data.bitmap = bitmap;
   ++pending_writes_;
@@ -644,6 +701,13 @@
 }
 
 void ClipboardHostImpl::CommitWrite() {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    clipboard_writer_->Reset();
+    ResetClipboardWriter();
+    pending_commit_write_ = false;
+    return;
+  }
   if (pending_writes_ != 0) {
     // This branch indicates that some callbacks passed to
     // `IsClipboardCopyAllowedByPolicy` still haven't been called, so committing
@@ -668,7 +732,9 @@
 
 void ClipboardHostImpl::ReadAvailableCustomAndStandardFormats(
     ReadAvailableCustomAndStandardFormatsCallback callback) {
-  if (!IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
                               render_frame_host())) {
     std::move(callback).Run(std::vector<std::u16string>());
     return;
@@ -690,7 +756,9 @@
 void ClipboardHostImpl::ReadUnsanitizedCustomFormat(
     const std::u16string& format,
     ReadUnsanitizedCustomFormatCallback callback) {
-  if (!IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard) ||
+      !IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
                               render_frame_host())) {
     std::move(callback).Run(mojo_base::BigBuffer());
     return;
@@ -772,6 +840,10 @@
 void ClipboardHostImpl::WriteUnsanitizedCustomFormat(
     const std::u16string& format,
     mojo_base::BigBuffer data) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   // `kMaxFormatSize` & `kMaxDataSize` includes the null terminator.
   if (format.length() >= blink::mojom::ClipboardHost::kMaxFormatSize)
     return;
@@ -943,7 +1015,8 @@
 }
 
 void ClipboardHostImpl::OnClipboardDataChanged() {
-  if (!listening_to_clipboard_ || !clipboard_listener_) {
+  if (!listening_to_clipboard_ || !clipboard_listener_ ||
+      !render_frame_host().IsActive()) {
     return;
   }
 
@@ -1020,6 +1093,10 @@
 
 void ClipboardHostImpl::RegisterClipboardListener(
     mojo::PendingRemote<blink::mojom::ClipboardListener> listener) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   // Replace the current listener with the new one
   clipboard_listener_.reset();
   clipboard_listener_.Bind(std::move(listener));
diff --git a/content/browser/renderer_host/clipboard_host_impl.h b/content/browser/renderer_host/clipboard_host_impl.h
index d297a61..413b2a3f 100644
--- a/content/browser/renderer_host/clipboard_host_impl.h
+++ b/content/browser/renderer_host/clipboard_host_impl.h
@@ -18,6 +18,7 @@
 #include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/common/content_export.h"
 #include "content/public/browser/clipboard_types.h"
+#include "content/public/browser/disallow_activation_reason.h"
 #include "content/public/browser/document_service.h"
 #include "mojo/public/cpp/base/big_buffer.h"
 #include "mojo/public/cpp/bindings/remote.h"
@@ -102,6 +103,8 @@
   FRIEND_TEST_ALL_PREFIXES(ClipboardHostImplChangeTest, AddClipboardListener);
   FRIEND_TEST_ALL_PREFIXES(ClipboardHostImplChangeTest,
                            ClipboardListenerDisconnect);
+  FRIEND_TEST_ALL_PREFIXES(ClipboardHostImplChangeTest,
+                           NoNotificationToInactiveDocument);
 
   // mojom::ClipboardHost
   void RegisterClipboardListener(
diff --git a/content/browser/renderer_host/clipboard_host_impl_mac.mm b/content/browser/renderer_host/clipboard_host_impl_mac.mm
index 7bb1d48..0e8d4f29 100644
--- a/content/browser/renderer_host/clipboard_host_impl_mac.mm
+++ b/content/browser/renderer_host/clipboard_host_impl_mac.mm
@@ -24,6 +24,10 @@
 }  // namespace
 
 void ClipboardHostImpl::WriteStringToFindPboard(const std::u16string& text) {
+  if (render_frame_host().IsInactiveAndDisallowActivation(
+          DisallowActivationReasonId::kClipboard)) {
+    return;
+  }
   if (text.length() <= kMaxFindPboardStringLength) {
     NSString* nsText = base::SysUTF16ToNSString(text);
     if (nsText) {
diff --git a/content/public/browser/disallow_activation_reason.h b/content/public/browser/disallow_activation_reason.h
index 14a0da4..d9a25581 100644
--- a/content/public/browser/disallow_activation_reason.h
+++ b/content/public/browser/disallow_activation_reason.h
@@ -68,6 +68,7 @@
   kDidCommitNavigation = 44,
   kStartDragging = 45,
   kWindowPlacement = 46,
+  kClipboard = 47,
   // New entries go above here. New entries should be added to
   // tools/metrics/histograms/enums.xml .
   kMinEmbedderDisallowActivationReason = 2 << 16,
diff --git a/tools/metrics/histograms/enums.xml b/tools/metrics/histograms/enums.xml
index bdb3fe4c..ac8182b7 100644
--- a/tools/metrics/histograms/enums.xml
+++ b/tools/metrics/histograms/enums.xml
@@ -5970,6 +5970,10 @@
     A window.moveTo() / moveBy() / resizeTo() / resizeBy() / SetWindowRect() IPC
     arrived for a frame in BFCache or pending deletion.
   </int>
+  <int value="47" label="kClipboard">
+    A clipboard read/write or listener registration IPC arrived for an inactive
+    frame.
+  </int>
 </enum>
 
 <enum name="IncidentType">
diff --git a/content/browser/renderer_host/clipboard_host_impl_unittest.cc b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
index 285c297f..9c8cb86c 100644
--- a/content/browser/renderer_host/clipboard_host_impl_unittest.cc
+++ b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
@@ -168,6 +168,48 @@
                                       &unused_sequence_number);
 }
 
+TEST_F(ClipboardHostImplTest, WriteFromInactiveDocumentIsIgnored) {
+  const std::u16string kInitial = u"initial";
+  {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteText(kInitial);
+  }
+
+  static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+  mojo_clipboard()->WriteText(u"from-inactive-document");
+  mojo_clipboard()->CommitWrite();
+  mojo_clipboard().FlushForTesting();
+
+  base::test::TestFuture<std::u16string> future;
+  system_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste,
+                               /*data_dst=*/std::nullopt, future.GetCallback());
+  EXPECT_EQ(kInitial, future.Take());
+}
+
+TEST_F(ClipboardHostImplTest, ReadFromInactiveDocumentIsIgnored) {
+  {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteText(u"clipboard-text");
+  }
+
+  static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+  std::u16string result = u"non-empty";
+  mojo_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste, &result);
+  EXPECT_TRUE(result.empty());
+
+  std::vector<std::u16string> types = {u"non-empty"};
+  mojo_clipboard()->ReadAvailableTypes(ui::ClipboardBuffer::kCopyPaste, &types);
+  EXPECT_TRUE(types.empty());
+}
+
 TEST_F(ClipboardHostImplTest, ReadAvailableTypes_TextUriList) {
   std::vector<std::u16string> types;
 
@@ -1076,8 +1118,10 @@
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/clipboard_host_impl_unittest.cc b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
index 285c297f..9c8cb86c 100644
--- a/content/browser/renderer_host/clipboard_host_impl_unittest.cc
+++ b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
@@ -168,6 +168,48 @@
                                       &unused_sequence_number);
 }
 
+TEST_F(ClipboardHostImplTest, WriteFromInactiveDocumentIsIgnored) {
+  const std::u16string kInitial = u"initial";
+  {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteText(kInitial);
+  }
+
+  static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+  mojo_clipboard()->WriteText(u"from-inactive-document");
+  mojo_clipboard()->CommitWrite();
+  mojo_clipboard().FlushForTesting();
+
+  base::test::TestFuture<std::u16string> future;
+  system_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste,
+                               /*data_dst=*/std::nullopt, future.GetCallback());
+  EXPECT_EQ(kInitial, future.Take());
+}
+
+TEST_F(ClipboardHostImplTest, ReadFromInactiveDocumentIsIgnored) {
+  {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteText(u"clipboard-text");
+  }
+
+  static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+  std::u16string result = u"non-empty";
+  mojo_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste, &result);
+  EXPECT_TRUE(result.empty());
+
+  std::vector<std::u16string> types = {u"non-empty"};
+  mojo_clipboard()->ReadAvailableTypes(ui::ClipboardBuffer::kCopyPaste, &types);
+  EXPECT_TRUE(types.empty());
+}
+
 TEST_F(ClipboardHostImplTest, ReadAvailableTypes_TextUriList) {
   std::vector<std::u16string> types;
 
@@ -1076,8 +1118,10 @@
     return fake_clipboard_host_impl_;
   }
 
- private:
+ protected:
   mojo::Remote<blink::mojom::ClipboardHost> remote_;
+
+ private:
   // `ClipboardHostImpl` is a `DocumentService` and manages its own
   // lifetime.
   raw_ptr<ClipboardHostImpl> fake_clipboard_host_impl_;
@@ -1131,6 +1175,22 @@
   base::RunLoop().RunUntilIdle();
 }
 
+TEST_F(ClipboardHostImplChangeTest, NoNotificationToInactiveDocument) {
+  auto mock_listener = std::make_unique<MockClipboardListener>();
+  EXPECT_CALL(*mock_listener, OnClipboardDataChanged).Times(0);
+
+  clipboard_host_impl()->RegisterClipboardListener(mock_listener->GetRemote());
+  EXPECT_TRUE(clipboard_host_impl()->listening_to_clipboard_);
+
+  static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+  ui::ClipboardMonitor::GetInstance()->NotifyClipboardDataChanged();
+  remote_.FlushForTesting();
+}
+
 TEST_F(ClipboardHostImplChangeTest, ClipboardListenerDisconnect) {
   // Initially, the clipboard host should not be listening to clipboard changes
   EXPECT_FALSE(clipboard_host_impl()->listening_to_clipboard_);
Loading diff…

Original Bug Report

reported by [email protected]

Clipboard access by background (BFCached) pages via ClipboardHostImpl

Flapjack, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A compromised renderer in the Back/Forward Cache (BFCache) can potentially read, write, or monitor the system clipboard. The ClipboardHostImpl Mojo interface lacks active document checks, and background pages can exploit tab-wide user interaction states to bypass clipboard read permissions.

Affected files:

  • content/browser/renderer_host/clipboard_host_impl.cc

Estimated timestamp from git blame: 2021-02-12

Description

The ClipboardHostImpl class (content/browser/renderer_host/clipboard_host_impl.cc) handles clipboard-related Mojo IPCs from renderer processes. It inherits from DocumentService, which ties the Mojo connection to a RenderFrameHost. However, when a document enters the Back/Forward Cache (BFCache), the RenderFrameHost is preserved, and the Mojo connection is not severed or paused.

Because ClipboardHostImpl does not verify if the calling document is actively displayed (e.g., using render_frame_host().IsActive()), a compromised renderer can continue to invoke clipboard methods from the background.

This lack of lifecycle checking exposes three distinct potential attack vectors:

  1. Bypassing Paste Restrictions (Read): Operations like ReadText require a user gesture. The browser checks this via IsRendererPasteAllowed(), which delegates to ChromeContentBrowserClient::IsClipboardPasteAllowed(). This function checks WebContents::HasRecentInteraction(). Crucially, interaction state is tracked at the tab (WebContents) level. If the user interacts with the newly loaded, active page in the same tab, the BFCached document inherits this interaction state and can silently read the clipboard.
  2. Silent Clipboard Writes: Methods like WriteText and CommitWrite lack both user interaction checks and active document checks in the browser process. A background page can silently overwrite the user’s clipboard.
  3. Silent Clipboard Monitoring: A background page can call RegisterClipboardListener(). Because there are no active checks during registration or when events fire (OnClipboardDataChanged), the BFCached renderer receives continuous, asynchronous notifications whenever the user copies new data across the OS.

Suggested Attacker Steps

Note: These are potential steps based on code analysis; a working proof of concept has not been executed.

  1. An attacker lures the victim to a malicious website.
  2. The attacker exploits a separate memory corruption vulnerability to gain Remote Code Execution (RCE) within the sandboxed renderer process.
  3. The victim navigates away to a legitimate site in the same tab. Chrome places the malicious page into the BFCache.
  4. The attacker uses their RCE to bypass the renderer’s application-level freeze, keeping their threads alive.
  5. To steal clipboard data (Read): The user clicks or types on the new, legitimate page. Within 5 seconds, the attacker’s BFCached renderer sends a ReadText Mojo IPC. The browser process checks the tab’s interaction state, sees the recent interaction, and returns the clipboard contents to the attacker.
  6. To monitor the clipboard: The attacker sends a RegisterClipboardListener IPC from the BFCache and silently receives all subsequent clipboard changes.

Suggested Fix

The most direct fix is to ensure ClipboardHostImpl does not process IPCs for inactive documents. Add checks at the beginning of all IPC entry points in content/browser/renderer_host/clipboard_host_impl.cc:

if (!render_frame_host().IsActive()) {
  // Run callback with empty/default data or report a bad message.
  return;
}

Alternatively, consider evaluating if the Mojo Binder Policy for ClipboardHost should be updated to defer or cancel messages when a document enters the BFCache, similar to how it handles Prerendering.

Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker