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

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in DataTransfer
DescriptionIncorrect authorization in DataTransfer
ComponentDataTransfer
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.
Tracker517732336
Fix commitf4035ca8d115 (chromium/src) +181/-9
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-08-04 (public git)
Disclosed2026-09-08
Patch gap35 days — fix public in source before the advisory

Background

`IsClipboardPasteAllowed`
the ChromeContentBrowserClient embedder hook that decides whether a given RenderFrameHost may read from the system clipboard.
`CLIPBOARD_READ_WRITE` permission
a persistent content setting that, once granted to an origin, lets that origin’s pages read clipboard data via the async Clipboard API.
`RenderFrameHostImpl::IsFocused`
a browser-process query that reports whether a frame is (or is an ancestor of) the currently focused frame within a top-level widget that itself holds OS focus.
Frame focus invariant
Blink already gates clipboard reads on Document::hasFocus() in the renderer, so the browser-side authorization is expected to enforce the same requirement rather than trusting the renderer.

Root Cause Analysis

In the pre-patch ChromeContentBrowserClient::IsClipboardPasteAllowed(), once the CLIPBOARD_READ_WRITE permission status was blink::mojom::PermissionStatus::GRANTED, the function immediately executed return true, authorizing the clipboard read unconditionally. This meant the browser-side authorization relied solely on the persistent permission grant and never verified that the requesting frame was actually focused, so a backgrounded tab or an unfocused subframe belonging to a permitted origin could scrape clipboard contents. The invariant violated is that clipboard reads must originate from a focused frame — a check Blink performs in the renderer via Document::hasFocus() but which a compromised or scripted renderer could bypass, leaving the browser as the only trustworthy enforcement point.

The fix TIGHTENS the check: on the GRANTED path it now requires render_frame_host->IsFocused() (or a trusted WebUI/Isolated-Web-App scheme) before returning true, moving the focus decision into the trusted browser process. To make that possible, IsFocused() is promoted to the public content::RenderFrameHost interface, and its implementation is refined so a top-level widget with OS focus but no yet-recorded focused subframe defaults to treating the main frame as focused (behind the kDefaultToMainFrameFocusWhenNoSubframeFocused killswitch), avoiding false negatives for freshly opened windows.

Key insight
The single mistake was treating a persistent permission grant as sufficient authorization for a clipboard read, ignoring the transient focus state that Blink already requires; the fix closes the gap by making the trusted browser process demand RenderFrameHost::IsFocused() on the granted path, so unfocused background tabs and subframes are denied.

Attack Path

  1. Obtain the permission A malicious or attacker-controlled origin is granted CLIPBOARD_READ_WRITE (persistent clipboard-read) permission at some earlier point.
  2. Lose focus The page arranges to run while unfocused, e.g. as a background tab, an unfocused subframe, or after opening a popup window that steals focus.
  3. Request a clipboard read Script in the unfocused frame invokes the clipboard read API, which reaches the browser’s IsClipboardPasteAllowed().
  4. Bypass the focus check Because the pre-patch code returned true as soon as the permission was GRANTED, the browser authorizes the read despite the frame not being focused, disclosing clipboard contents to the frame.

Impact Assessment

An attacker with a granted clipboard-read permission gains the ability to read the user’s clipboard from web content that is not focused (background tab or unfocused subframe), an information-disclosure primitive exposing whatever the user has copied (passwords, tokens, personal data) to renderer-hosted attacker script. Exploitation requires that the origin already hold CLIPBOARD_READ_WRITE permission; no exploited-in-the-wild status or severity rating is stated in the provided advisory. The advisory classifies this as incorrect authorization in DataTransfer, and no escalation beyond clipboard information disclosure is demonstrated by the patch.

Changed Functions

FunctionChangeNotes
if
chrome/browser/chrome_content_browser_client.cc
modified
if
content/browser/renderer_host/render_frame_host_impl.cc
modified
ChromeContentBrowserClientClipboardTest
chrome/browser/chrome_content_browser_client_browsertest.cc
modified
TopChromeChromeContentBrowserClientTest
chrome/browser/chrome_content_browser_client_browsertest.cc
modified
TEST_F
content/browser/renderer_host/render_frame_host_impl_unittest.cc
modified
DestructionDelegate
content/browser/renderer_host/render_frame_host_impl_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/render_frame_host_impl.h
  • content/public/browser/render_frame_host.h
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/render_frame_host_impl_unittest.cc
  • chrome/browser/chrome_content_browser_client_browsertest.cc
  • chrome/browser/chrome_content_browser_client.cc

Audit Directions

  • Permission-only authorization
    Audit other ChromeContentBrowserClient and capability hooks that return true on a GRANTED permission status without checking transient state (focus, visibility, user activation), since persistent grants alone are not sufficient authorization.
  • Renderer-side checks not mirrored in the browser
    Look for security preconditions enforced only in Blink (e.g. Document::hasFocus(), user-gesture requirements) that lack a corresponding browser-process check, since a compromised renderer can skip them.
  • Background/subframe access to user data
    Review sinks that expose clipboard, geolocation, camera/mic, or similar sensitive data to ensure they distinguish focused foreground frames from unfocused background tabs and cross-origin subframes.
From f4035ca8d11599f567ef7cb486bf2adf28af5e32 Mon Sep 17 00:00:00 2001
From: Aman Verma <[email protected]>
Date: Tue, 04 Aug 2026 13:23:10 -0700
Subject: [PATCH] Enforce frame focus in IsClipboardPasteAllowed when permission granted

When web pages have granted CLIPBOARD_READ_WRITE permission, pasting
still requires that the requesting frame is focused. Previously, any
unfocused background tab or subframe could read clipboard data if
permission was granted.

This change enforces frame focus in IsClipboardPasteAllowed():
1. Exposes IsFocused() on content::RenderFrameHost and implements it
   on RenderFrameHostImpl. IsFocused() verifies that the top-level
   widget is focused and that the requesting frame matches or is an
   ancestor of the focused subframe in FrameTree. When no subframe is
   focused yet, it defaults to treating the main frame as focused,
   guarded by a killswitch feature flag:
   DefaultToMainFrameFocusWhenNoSubframeFocused (enabled by default).
2. In ChromeContentBrowserClient::IsClipboardPasteAllowed(), requires
   render_frame_host->IsFocused() when permission status is GRANTED [1].
3. Exempts trusted WebUI schemes using content::HasWebUIScheme(url)
   and Isolated Web Apps (isolated-app://), such as ChromeOS Files App,
   Terminal, and DevTools, from the focus requirement when permission
   is granted. These system apps often handle clipboard commands via
   backgrounded UIs, context menus, or standalone app windows where the
   page is not focused (including in automated browser tests).
4. Adds regression browser tests in
   ChromeContentBrowserClientClipboardTest.

[1] Mirroring Blink's existing Document::hasFocus() clipboard check in
ClipboardPromise::ValidatePreconditions:
https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/clipboard/clipboard_promise.cc;l=663-667;drc=4e1ed96056dab8a3938ced9ea26fd94c7228e7b5

Bug: 517732336
Change-Id: Ic5e283d6a7dc815adf4550a3c898a7f463582c3d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8175452
Reviewed-by: Charlie Reis <[email protected]>
Commit-Queue: Charlie Reis <[email protected]>
Auto-Submit: Aman Verma <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1673630}
---
diff --git a/chrome/browser/chrome_content_browser_client.cc b/chrome/browser/chrome_content_browser_client.cc
index 2236df8..1f491172 100644
--- a/chrome/browser/chrome_content_browser_client.cc
+++ b/chrome/browser/chrome_content_browser_client.cc
@@ -862,6 +862,15 @@
   return url.ReplaceComponents(replacements);
 }
 
+bool IsIsolatedWebAppUrl(const GURL& url) {
+#if BUILDFLAG(IS_WIN) || BUILDFLAG(IS_MAC) || BUILDFLAG(IS_LINUX) || \
+    BUILDFLAG(IS_CHROMEOS)
+  return url.SchemeIs(webapps::kIsolatedAppScheme);
+#else
+  return false;
+#endif
+}
+
 // Handles the rewriting of the new tab page URL based on group policy.
 bool HandleNewTabPageLocationOverride(
     GURL* url,
@@ -7922,7 +7931,25 @@
                   blink::PermissionType::CLIPBOARD_READ_WRITE),
           render_frame_host);
   if (status == blink::mojom::PermissionStatus::GRANTED) {
-    return true;
+    // Standard web pages must hold frame focus to read clipboard data,
+    // preventing background tabs and subframes from scraping the clipboard.
+    //
+    // Trusted WebUI system apps (e.g., ChromeOS Files App), Isolated Web Apps,
+    // and DevTools are exempted because they often invoke clipboard commands
+    // via context menus, background UIs, or standalone windows where the page
+    // lacks focus (including in automated browser tests).
+    //
+    // We check the main frame's committed origin URL (rather than
+    // GetLastCommittedURL) to preserve origin inheritance for initial empty
+    // documents (e.g., about:blank popups created by trusted system apps),
+    // while ensuring sandboxed frames with opaque origins evaluate to an empty
+    // GURL and are safely excluded (see docs/security/origin-vs-url.md).
+    const GURL& url =
+        render_frame_host->GetMainFrame()->GetLastCommittedOrigin().GetURL();
+    if (content::HasWebUIScheme(url) || IsIsolatedWebAppUrl(url) ||
+        render_frame_host->IsFocused()) {
+      return true;
+    }
   }
 
 #if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 0ba81f0..4653a24e 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -385,6 +385,12 @@
 
 BASE_FEATURE(kDoNotEvictOnAXLocationChange, base::FEATURE_ENABLED_BY_DEFAULT);
 
+// When enabled, RenderFrameHost::IsFocused() treats the main frame as focused
+// by default when the top-level widget has OS focus but FrameTree does not yet
+// have a focused subframe (e.g., in newly opened windows or tabs).
+BASE_FEATURE(kDefaultToMainFrameFocusWhenNoSubframeFocused,
+             base::FEATURE_ENABLED_BY_DEFAULT);
+
 BASE_FEATURE(kEnforceUserActivationForBeforeUnload,
              base::FEATURE_ENABLED_BY_DEFAULT);
 }  // namespace features
@@ -13688,13 +13694,22 @@
 }
 
 bool RenderFrameHostImpl::IsFocused() {
-  if (!GetMainFrame()->GetRenderWidgetHost()->is_focused() ||
-      !frame_tree_->GetFocusedFrame()) {
+  if (!GetMainFrame()->GetRenderWidgetHost()->is_focused()) {
     return false;
   }
 
-  RenderFrameHostImpl* focused_rfh =
-      frame_tree_->GetFocusedFrame()->current_frame_host();
+  // If the top-level widget has OS focus but FrameTree does not yet have a
+  // focused subframe, treat the main frame as focused by default.
+  FrameTreeNode* focused_frame = frame_tree_->GetFocusedFrame();
+  if (!focused_frame) {
+    if (base::FeatureList::IsEnabled(
+            features::kDefaultToMainFrameFocusWhenNoSubframeFocused)) {
+      return this == GetMainFrame();
+    }
+    return false;
+  }
+
+  RenderFrameHostImpl* focused_rfh = focused_frame->current_frame_host();
   return focused_rfh == this ||
          focused_rfh->IsDescendantOfWithinFrameTree(this);
 }
diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
index 9ebee80d..da98afac 100644
--- a/content/browser/renderer_host/render_frame_host_impl.h
+++ b/content/browser/renderer_host/render_frame_host_impl.h
@@ -271,6 +271,9 @@
 
 CONTENT_EXPORT BASE_DECLARE_FEATURE(kDoNotEvictOnAXLocationChange);
 
+CONTENT_EXPORT BASE_DECLARE_FEATURE(
+    kDefaultToMainFrameFocusWhenNoSubframeFocused);
+
 CONTENT_EXPORT BASE_DECLARE_FEATURE(kEnforceUserActivationForBeforeUnload);
 }  // namespace features
 
@@ -1714,10 +1717,7 @@
 
   // END IPC REVIEW BOUNDARY
 
-  // Returns whether the frame is focused. A frame is considered focused when it
-  // is the parent chain of the focused frame within the frame tree. In
-  // addition, its associated RenderWidgetHost has to be focused.
-  bool IsFocused();
+  bool IsFocused() override;
 
   // Sets the WebUI owned by `request` as the WebUI for this RenderFrameHost,
   // which is based on the provided `request`'s URL.
diff --git a/content/public/browser/render_frame_host.h b/content/public/browser/render_frame_host.h
index 3ad0bf9..eb60aab 100644
--- a/content/public/browser/render_frame_host.h
+++ b/content/public/browser/render_frame_host.h
@@ -813,6 +813,11 @@
   // DisallowActivationReasonId.kMinEmbedderDisallowActivationReason.
   virtual bool IsInactiveAndDisallowActivation(uint64_t reason) = 0;
 
+  // Returns whether the frame is focused. A frame is considered focused when it
+  // is the parent chain of the focused frame within the frame tree. In
+  // addition, its associated RenderWidgetHost has to be focused.
+  virtual bool IsFocused() = 0;
+
   // Get the number of proxies to this frame, in all processes. Exposed for
   // use by resource metrics.
   virtual size_t GetProxyCount() = 0;
diff --git a/chrome/browser/chrome_content_browser_client_browsertest.cc b/chrome/browser/chrome_content_browser_client_browsertest.cc
index 07f0f88..3442710 100644
--- a/chrome/browser/chrome_content_browser_client_browsertest.cc
+++ b/chrome/browser/chrome_content_browser_client_browsertest.cc
@@ -24,6 +24,7 @@
 #include "chrome/browser/accessibility/page_colors_controller_factory.h"
 #include "chrome/browser/browser_features.h"
 #include "chrome/browser/browser_process.h"
+#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
 #include "chrome/browser/custom_handlers/protocol_handler_registry_factory.h"
 #include "chrome/browser/enterprise/connectors/test/fake_clipboard_request_handler.h"
 #include "chrome/browser/external_protocol/external_protocol_handler.h"
@@ -50,6 +51,7 @@
 #include "chrome/test/base/in_process_browser_test.h"
 #include "chrome/test/base/ui_test_utils.h"
 #include "components/content_settings/core/browser/cookie_settings.h"
+#include "components/content_settings/core/browser/host_content_settings_map.h"
 #include "components/content_settings/core/common/pref_names.h"
 #include "components/custom_handlers/protocol_handler.h"
 #include "components/custom_handlers/protocol_handler_registry.h"
@@ -1774,6 +1776,102 @@
 
 #endif  // BUILDFLAG(ENTERPRISE_CONTENT_ANALYSIS)
 
+class ChromeContentBrowserClientClipboardTest : public InProcessBrowserTest {
+ public:
+  ChromeContentBrowserClientClipboardTest() = default;
+
+  void SetPermission(const GURL& url,
+                     ContentSettingsType type,
+                     ContentSetting setting) {
+    HostContentSettingsMapFactory::GetForProfile(browser()->GetProfile())
+        ->SetContentSettingDefaultScope(url, url, type, setting);
+  }
+
+  bool IsClipboardPasteAllowed(content::RenderFrameHost* rfh) {
+    return content::GetContentClientForTesting()
+        ->browser()
+        ->IsClipboardPasteAllowed(rfh);
+  }
+};
+
+// Verifies that even when persistent clipboard permission is granted,
+// IsClipboardPasteAllowed requires the requesting frame to be focused.
+// If a page loses focus (e.g., when a popup window is opened), clipboard
+// access is disallowed until focus is restored.
+IN_PROC_BROWSER_TEST_F(ChromeContentBrowserClientClipboardTest,
+                       PasteAllowedByPermission_RequiresFrameFocus) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  GURL test_url = embedded_test_server()->GetURL("/empty.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), test_url));
+
+  content::RenderFrameHost* rfh = browser()
+                                      ->tab_strip_model()
+                                      ->GetActiveWebContents()
+                                      ->GetPrimaryMainFrame();
+  SetPermission(test_url, ContentSettingsType::CLIPBOARD_READ_WRITE,
+                CONTENT_SETTING_ALLOW);
+  rfh->GetRenderWidgetHost()->Focus();
+
+  // Active, focused foreground tab with permission granted returns true.
+  EXPECT_TRUE(IsClipboardPasteAllowed(rfh));
+
+  // Even with permission granted, clipboard access is disallowed while
+  // unfocused.
+  rfh->GetRenderWidgetHost()->Blur();
+  EXPECT_FALSE(rfh->IsFocused());
+  EXPECT_FALSE(IsClipboardPasteAllowed(rfh));
+
+  // Restoring focus allows clipboard access again.
+  rfh->GetRenderWidgetHost()->Focus();
+  EXPECT_TRUE(rfh->IsFocused());
+  EXPECT_TRUE(IsClipboardPasteAllowed(rfh));
+}
+
+// Verifies that IsClipboardPasteAllowed mirrors Blink's Document::hasFocus()
+// for subframes:
+// - Unfocused child iframes are blocked from reading the clipboard.
+// - Focused child iframes are allowed to read the clipboard.
+// - When a child iframe is focused, its ancestor main frame is also allowed.
+// - When the top-level tab loses focus, all frames are blocked.
+IN_PROC_BROWSER_TEST_F(ChromeContentBrowserClientClipboardTest,
+                       PasteAllowedByPermission_Subframes) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  GURL test_url = embedded_test_server()->GetURL("/iframe.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), test_url));
+
+  content::WebContents* web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+  content::RenderFrameHost* parent_rfh = web_contents->GetPrimaryMainFrame();
+  content::RenderFrameHost* child_rfh = content::ChildFrameAt(parent_rfh, 0);
+  ASSERT_TRUE(child_rfh);
+
+  SetPermission(test_url, ContentSettingsType::CLIPBOARD_READ_WRITE,
+                CONTENT_SETTING_ALLOW);
+  parent_rfh->GetRenderWidgetHost()->Focus();
+
+  // An unfocused child iframe is not allowed to read the clipboard.
+  EXPECT_FALSE(child_rfh->IsFocused());
+  EXPECT_FALSE(IsClipboardPasteAllowed(child_rfh));
+
+  // Focusing the child iframe in the frame tree allows clipboard access.
+  ASSERT_TRUE(
+      content::ExecJs(parent_rfh, "document.getElementById('test').focus();"));
+  ASSERT_TRUE(content::ExecJs(child_rfh, "window.focus();"));
+  EXPECT_TRUE(child_rfh->IsFocused());
+  EXPECT_TRUE(IsClipboardPasteAllowed(child_rfh));
+
+  // When a child iframe is focused, its ancestor main frame is also allowed.
+  EXPECT_TRUE(parent_rfh->IsFocused());
+  EXPECT_TRUE(IsClipboardPasteAllowed(parent_rfh));
+
+  // When the top-level tab loses focus, both child and parent are blocked.
+  child_rfh->GetRenderWidgetHost()->Blur();
+  EXPECT_FALSE(child_rfh->IsFocused());
+  EXPECT_FALSE(IsClipboardPasteAllowed(child_rfh));
+  EXPECT_FALSE(parent_rfh->IsFocused());
+  EXPECT_FALSE(IsClipboardPasteAllowed(parent_rfh));
+}
+
 class TopChromeChromeContentBrowserClientTest
     : public ChromeContentBrowserClientBrowserTest {
  public:
diff --git a/content/browser/renderer_host/render_frame_host_impl_unittest.cc b/content/browser/renderer_host/render_frame_host_impl_unittest.cc
index 18a92c6..3ad9ef9 100644
--- a/content/browser/renderer_host/render_frame_host_impl_unittest.cc
+++ b/content/browser/renderer_host/render_frame_host_impl_unittest.cc
@@ -16,6 +16,7 @@
 #include "content/browser/bad_message.h"
 #include "content/browser/renderer_host/navigation_controller_impl.h"
 #include "content/browser/renderer_host/render_frame_host_manager.h"
+#include "content/browser/renderer_host/render_widget_host_impl.h"
 #include "content/browser/site_instance_impl.h"
 #include "content/common/content_navigation_policy.h"
 #include "content/common/features.h"
@@ -233,6 +234,32 @@
   EXPECT_EQ(GURL(url::kAboutBlankURL), main_rfh()->GetLastCommittedURL());
 }
 
+TEST_F(RenderFrameHostImplTest, DefaultToMainFrameWhenNoSubframeFocused) {
+  NavigateAndCommit(GURL("https://test.example.com"));
+
+  // Ensure top-level widget has OS focus.
+  RenderWidgetHostImpl* widget =
+      static_cast<RenderWidgetHostImpl*>(main_rfh()->GetRenderWidgetHost());
+  widget->Focus();
+  EXPECT_TRUE(widget->is_focused());
+
+  // In a unit test, FrameTree::GetFocusedFrame() starts as nullptr before any
+  // subframe focus IPC has been received.
+  EXPECT_EQ(nullptr, contents()->GetPrimaryFrameTree().GetFocusedFrame());
+
+  // By default (with killswitch kDefaultToMainFrameFocusWhenNoSubframeFocused
+  // enabled), the main frame is treated as focused when GetFocusedFrame() is
+  // null.
+  EXPECT_TRUE(main_test_rfh()->IsFocused());
+
+  // Disabling the killswitch falls back to returning false when
+  // GetFocusedFrame() is null.
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndDisableFeature(
+      features::kDefaultToMainFrameFocusWhenNoSubframeFocused);
+  EXPECT_FALSE(main_test_rfh()->IsFocused());
+}
+
 TEST_F(RenderFrameHostImplTest, ExitFullscreenDestruction) {
   class DestructionDelegate : public WebContentsDelegate {
    public:
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/chrome_content_browser_client_browsertest.cc b/chrome/browser/chrome_content_browser_client_browsertest.cc
index 07f0f88..3442710 100644
--- a/chrome/browser/chrome_content_browser_client_browsertest.cc
+++ b/chrome/browser/chrome_content_browser_client_browsertest.cc
@@ -24,6 +24,7 @@
 #include "chrome/browser/accessibility/page_colors_controller_factory.h"
 #include "chrome/browser/browser_features.h"
 #include "chrome/browser/browser_process.h"
+#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
 #include "chrome/browser/custom_handlers/protocol_handler_registry_factory.h"
 #include "chrome/browser/enterprise/connectors/test/fake_clipboard_request_handler.h"
 #include "chrome/browser/external_protocol/external_protocol_handler.h"
@@ -50,6 +51,7 @@
 #include "chrome/test/base/in_process_browser_test.h"
 #include "chrome/test/base/ui_test_utils.h"
 #include "components/content_settings/core/browser/cookie_settings.h"
+#include "components/content_settings/core/browser/host_content_settings_map.h"
 #include "components/content_settings/core/common/pref_names.h"
 #include "components/custom_handlers/protocol_handler.h"
 #include "components/custom_handlers/protocol_handler_registry.h"
@@ -1774,6 +1776,102 @@
 
 #endif  // BUILDFLAG(ENTERPRISE_CONTENT_ANALYSIS)
 
+class ChromeContentBrowserClientClipboardTest : public InProcessBrowserTest {
+ public:
+  ChromeContentBrowserClientClipboardTest() = default;
+
+  void SetPermission(const GURL& url,
+                     ContentSettingsType type,
+                     ContentSetting setting) {
+    HostContentSettingsMapFactory::GetForProfile(browser()->GetProfile())
+        ->SetContentSettingDefaultScope(url, url, type, setting);
+  }
+
+  bool IsClipboardPasteAllowed(content::RenderFrameHost* rfh) {
+    return content::GetContentClientForTesting()
+        ->browser()
+        ->IsClipboardPasteAllowed(rfh);
+  }
+};
+
+// Verifies that even when persistent clipboard permission is granted,
+// IsClipboardPasteAllowed requires the requesting frame to be focused.
+// If a page loses focus (e.g., when a popup window is opened), clipboard
+// access is disallowed until focus is restored.
+IN_PROC_BROWSER_TEST_F(ChromeContentBrowserClientClipboardTest,
+                       PasteAllowedByPermission_RequiresFrameFocus) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  GURL test_url = embedded_test_server()->GetURL("/empty.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), test_url));
+
+  content::RenderFrameHost* rfh = browser()
+                                      ->tab_strip_model()
+                                      ->GetActiveWebContents()
+                                      ->GetPrimaryMainFrame();
+  SetPermission(test_url, ContentSettingsType::CLIPBOARD_READ_WRITE,
+                CONTENT_SETTING_ALLOW);
+  rfh->GetRenderWidgetHost()->Focus();
+
+  // Active, focused foreground tab with permission granted returns true.
+  EXPECT_TRUE(IsClipboardPasteAllowed(rfh));
+
+  // Even with permission granted, clipboard access is disallowed while
+  // unfocused.
+  rfh->GetRenderWidgetHost()->Blur();
+  EXPECT_FALSE(rfh->IsFocused());
+  EXPECT_FALSE(IsClipboardPasteAllowed(rfh));
+
+  // Restoring focus allows clipboard access again.
+  rfh->GetRenderWidgetHost()->Focus();
+  EXPECT_TRUE(rfh->IsFocused());
+  EXPECT_TRUE(IsClipboardPasteAllowed(rfh));
+}
+
+// Verifies that IsClipboardPasteAllowed mirrors Blink's Document::hasFocus()
+// for subframes:
+// - Unfocused child iframes are blocked from reading the clipboard.
+// - Focused child iframes are allowed to read the clipboard.
+// - When a child iframe is focused, its ancestor main frame is also allowed.
+// - When the top-level tab loses focus, all frames are blocked.
+IN_PROC_BROWSER_TEST_F(ChromeContentBrowserClientClipboardTest,
+                       PasteAllowedByPermission_Subframes) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  GURL test_url = embedded_test_server()->GetURL("/iframe.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), test_url));
+
+  content::WebContents* web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+  content::RenderFrameHost* parent_rfh = web_contents->GetPrimaryMainFrame();
+  content::RenderFrameHost* child_rfh = content::ChildFrameAt(parent_rfh, 0);
+  ASSERT_TRUE(child_rfh);
+
+  SetPermission(test_url, ContentSettingsType::CLIPBOARD_READ_WRITE,
+                CONTENT_SETTING_ALLOW);
+  parent_rfh->GetRenderWidgetHost()->Focus();
+
+  // An unfocused child iframe is not allowed to read the clipboard.
+  EXPECT_FALSE(child_rfh->IsFocused());
+  EXPECT_FALSE(IsClipboardPasteAllowed(child_rfh));
+
+  // Focusing the child iframe in the frame tree allows clipboard access.
+  ASSERT_TRUE(
+      content::ExecJs(parent_rfh, "document.getElementById('test').focus();"));
+  ASSERT_TRUE(content::ExecJs(child_rfh, "window.focus();"));
+  EXPECT_TRUE(child_rfh->IsFocused());
+  EXPECT_TRUE(IsClipboardPasteAllowed(child_rfh));
+
+  // When a child iframe is focused, its ancestor main frame is also allowed.
+  EXPECT_TRUE(parent_rfh->IsFocused());
+  EXPECT_TRUE(IsClipboardPasteAllowed(parent_rfh));
+
+  // When the top-level tab loses focus, both child and parent are blocked.
+  child_rfh->GetRenderWidgetHost()->Blur();
+  EXPECT_FALSE(child_rfh->IsFocused());
+  EXPECT_FALSE(IsClipboardPasteAllowed(child_rfh));
+  EXPECT_FALSE(parent_rfh->IsFocused());
+  EXPECT_FALSE(IsClipboardPasteAllowed(parent_rfh));
+}
+
 class TopChromeChromeContentBrowserClientTest
     : public ChromeContentBrowserClientBrowserTest {
  public:
diff --git a/content/browser/renderer_host/render_frame_host_impl_unittest.cc b/content/browser/renderer_host/render_frame_host_impl_unittest.cc
index 18a92c6..3ad9ef9 100644
--- a/content/browser/renderer_host/render_frame_host_impl_unittest.cc
+++ b/content/browser/renderer_host/render_frame_host_impl_unittest.cc
@@ -16,6 +16,7 @@
 #include "content/browser/bad_message.h"
 #include "content/browser/renderer_host/navigation_controller_impl.h"
 #include "content/browser/renderer_host/render_frame_host_manager.h"
+#include "content/browser/renderer_host/render_widget_host_impl.h"
 #include "content/browser/site_instance_impl.h"
 #include "content/common/content_navigation_policy.h"
 #include "content/common/features.h"
@@ -233,6 +234,32 @@
   EXPECT_EQ(GURL(url::kAboutBlankURL), main_rfh()->GetLastCommittedURL());
 }
 
+TEST_F(RenderFrameHostImplTest, DefaultToMainFrameWhenNoSubframeFocused) {
+  NavigateAndCommit(GURL("https://test.example.com"));
+
+  // Ensure top-level widget has OS focus.
+  RenderWidgetHostImpl* widget =
+      static_cast<RenderWidgetHostImpl*>(main_rfh()->GetRenderWidgetHost());
+  widget->Focus();
+  EXPECT_TRUE(widget->is_focused());
+
+  // In a unit test, FrameTree::GetFocusedFrame() starts as nullptr before any
+  // subframe focus IPC has been received.
+  EXPECT_EQ(nullptr, contents()->GetPrimaryFrameTree().GetFocusedFrame());
+
+  // By default (with killswitch kDefaultToMainFrameFocusWhenNoSubframeFocused
+  // enabled), the main frame is treated as focused when GetFocusedFrame() is
+  // null.
+  EXPECT_TRUE(main_test_rfh()->IsFocused());
+
+  // Disabling the killswitch falls back to returning false when
+  // GetFocusedFrame() is null.
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndDisableFeature(
+      features::kDefaultToMainFrameFocusWhenNoSubframeFocused);
+  EXPECT_FALSE(main_test_rfh()->IsFocused());
+}
+
 TEST_F(RenderFrameHostImplTest, ExitFullscreenDestruction) {
   class DestructionDelegate : public WebContentsDelegate {
    public:
Loading diff…

Original Bug Report

reported by [email protected]

Background clipboard exfiltration via IsClipboardPasteAllowed when permission is granted

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

Overview: A potential vulnerability in Chrome’s browser-side clipboard validation logic allows a compromised background renderer to silently read the OS clipboard if persistent clipboard permission was previously granted. While renderer-side checks require page focus, the browser-side security recheck does not validate document focus or visibility when verifying the permission status. This enables a background renderer to exfiltrate sensitive clipboard data without any active window interaction.

Affected files:

  • chrome/browser/chrome_content_browser_client.cc
  • content/browser/renderer_host/clipboard_host_impl.cc

Estimated timestamp from git blame: 2021-02-12

Summary

There is a potential security bypass in the browser-side clipboard permission verification. When a site has been persistently granted the CLIPBOARD_READ_WRITE permission, the browser-side validation in ChromeContentBrowserClient::IsClipboardPasteAllowed fails to check if the requesting frame has document focus or is visible. This allows a compromised renderer process to silently exfiltrate the system clipboard in the background, bypassing the renderer-side focus constraints enforced by Blink.

Root Cause Analysis

In third_party/blink/renderer/modules/clipboard/clipboard_promise.cc (line 661), the renderer-side API strictly enforces document focus before allowing a clipboard read request to proceed:

if (!window.document()->hasFocus()) {
  script_promise_resolver_->RejectWithDOMException(
      DOMExceptionCode::kNotAllowedError, "Document is not focused.");
  return;
}

However, a compromised renderer can bypass Blink’s checks and communicate directly with the browser process via the blink::mojom::ClipboardHost Mojo interface.

When the browser process receives a request such as ReadText in content/browser/renderer_host/clipboard_host_impl.cc (line 231), it performs a security recheck by calling IsRendererPasteAllowed (line 662), which delegates to:

GetContentClient()->browser()->IsClipboardPasteAllowed(&render_frame_host);

The implementation of IsClipboardPasteAllowed in chrome/browser/chrome_content_browser_client.cc (lines 7935-7958) checks:

  1. User activation via WebContentsImpl::HasRecentInteraction() (returns false if the page is in the background and has not been interacted with recently).
  2. The persistent clipboard permission status:
blink::mojom::PermissionStatus status =
    permission_controller->GetPermissionStatusForCurrentDocument(
        content::PermissionDescriptorUtil::
            CreatePermissionDescriptorForPermissionType(
                blink::PermissionType::CLIPBOARD_READ_WRITE),
        render_frame_host);
if (status == blink::mojom::PermissionStatus::GRANTED) {
  return true; // <-- Focus/visibility is not verified here!
}

If the permission is GRANTED (e.g., the user previously permitted the origin to access the clipboard), the browser-side check returns true unconditionally. Neither ClipboardHostImpl nor ChromeContentBrowserClient validates that the requesting RenderFrameHost is focused or currently visible.

Potential Attack Steps

(Note: As our tooling agent does not have the ability to run code, these are potential trigger steps.)

  1. The user visits an origin (e.g., https://example.com) and persistently grants the clipboard read/write permission (via the standard permission prompt).
  2. The renderer process for https://example.com is compromised via an independent renderer exploit, giving the attacker arbitrary code execution within the sandbox.
  3. The user switches to a different tab or minimizes the browser window, leaving the attacker’s tab backgrounded and unfocused.
  4. The compromised renderer makes a direct Mojo IPC call to blink::mojom::ClipboardHost::ReadText bypassing Blink’s renderer-side checks.
  5. In the browser process, ClipboardHostImpl queries IsClipboardPasteAllowed.
  6. Since the origin has persistent permission, IsClipboardPasteAllowed evaluates to true despite the tab being unfocused and invisible.
  7. The browser process reads the live system clipboard contents and returns them over Mojo to the compromised renderer in the background.

Suggested Fix

Modify ChromeContentBrowserClient::IsClipboardPasteAllowed to ensure that when authorizing paste operations based on the persistent permission status branch (rather than the user activation branch), the requesting frame’s visibility status or document focus is validated. For example, verify that the frame is visible:

if (status == blink::mojom::PermissionStatus::GRANTED) {
  return render_frame_host->GetVisibilityState() == content::PageVisibilityState::kVisible;
}

Alternatively, enforce that the requesting frame is the focused frame in the active tab.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


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