CVE-2026-87594
Overview
Background
- `IsClipboardPasteAllowed`
- the
ChromeContentBrowserClientembedder hook that decides whether a givenRenderFrameHostmay 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.
RenderFrameHost::IsFocused() on the granted path, so unfocused background tabs and subframes are denied.Attack Path
- Obtain the permission
A malicious or attacker-controlled origin is granted
CLIPBOARD_READ_WRITE(persistent clipboard-read) permission at some earlier point. - 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.
- Request a clipboard read
Script in the unfocused frame invokes the clipboard read API, which reaches the browser’s
IsClipboardPasteAllowed(). - Bypass the focus check
Because the pre-patch code returned
trueas soon as the permission wasGRANTED, the browser authorizes the read despite the frame not being focused, disclosing clipboard contents to the frame.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/chrome_content_browser_client.cc |
modified | |
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified | |
ChromeContentBrowserClientClipboardTestchrome/browser/chrome_content_browser_client_browsertest.cc |
modified | |
TopChromeChromeContentBrowserClientTestchrome/browser/chrome_content_browser_client_browsertest.cc |
modified | |
TEST_Fcontent/browser/renderer_host/render_frame_host_impl_unittest.cc |
modified | |
DestructionDelegatecontent/browser/renderer_host/render_frame_host_impl_unittest.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.hcontent/public/browser/render_frame_host.hcontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/render_frame_host_impl_unittest.ccchrome/browser/chrome_content_browser_client_browsertest.ccchrome/browser/chrome_content_browser_client.cc
Audit Directions
- Permission-only authorizationAudit other
ChromeContentBrowserClientand capability hooks thatreturn trueon aGRANTEDpermission status without checking transient state (focus, visibility, user activation), since persistent grants alone are not sufficient authorization. - Renderer-side checks not mirrored in the browserLook 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 dataReview 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.
Patch
From f4035ca8d11599f567ef7cb486bf2adf28af5e32 Mon Sep 17 00:00:00 2001
From: Aman Verma <amanvr@google.com>
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 <creis@chromium.org>
Commit-Queue: Charlie Reis <creis@chromium.org>
Auto-Submit: Aman Verma <amanvr@google.com>
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:
Regression Test / PoC
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:
Original Bug Report
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.cccontent/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:
- User activation via
WebContentsImpl::HasRecentInteraction()(returnsfalseif the page is in the background and has not been interacted with recently). - 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.)
- The user visits an origin (e.g.,
https://example.com) and persistently grants the clipboard read/write permission (via the standard permission prompt). - The renderer process for
https://example.comis compromised via an independent renderer exploit, giving the attacker arbitrary code execution within the sandbox. - The user switches to a different tab or minimizes the browser window, leaving the attacker’s tab backgrounded and unfocused.
- The compromised renderer makes a direct Mojo IPC call to
blink::mojom::ClipboardHost::ReadTextbypassing Blink’s renderer-side checks. - In the browser process,
ClipboardHostImplqueriesIsClipboardPasteAllowed. - Since the origin has persistent permission,
IsClipboardPasteAllowedevaluates totruedespite the tab being unfocused and invisible. - 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.