CVE-2026-113290
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/exported/web_view_impl.cc |
modified |
Files Changed
third_party/blink/renderer/platform/runtime_enabled_features.json5third_party/blink/renderer/core/html/forms/html_input_element.cccontent/browser/renderer_host/render_frame_host_impl.ccthird_party/blink/renderer/core/html/forms/color_input_type.ccthird_party/blink/renderer/core/html/forms/html_select_element.ccthird_party/blink/renderer/core/exported/web_view_impl.cccontent/browser/security_exploit_browsertest.cc
Patch
From a64fc96ef2ffe58da607cecc59c2257d73ffae4f Mon Sep 17 00:00:00 2001
From: David Liu <lcdavid@google.com>
Date: Thu, 24 Sep 2026 07:22:54 -0700
Subject: [PATCH] [input] Make popup widget consume user activation in browser
Require and consume transient user activation in the browser process
in RenderFrameHostImpl::CreateNewPopupWidget, guarded by the
PopupWidgetRequiresUserActivation kill switch. In the renderer, move
LocalFrame::ConsumeTransientUserActivation to after opening the
picker (under the same flag) so browser-side activation is still
active when the popup widget IPC arrives, and guard
WebViewImpl::OpenPagePopup against re-entrant popup creation if
CancelPagePopup fires a synchronous change event that opens another
popup.
Fixed: 506152584
Change-Id: I5cf34ee08f237a14004f342e7f90879142c91797
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8378165
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Commit-Queue: David Liu <lcdavid@google.com>
Reviewed-by: Mustaq Ahmed <mustaq@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1704274}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 8783584..41e031a 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -11186,6 +11186,17 @@
return;
}
+ // Form-control popups require and consume transient user activation, which
+ // we enforce on the browser side to guard against compromised renderers.
+ CHECK(owner_);
+ if (base::FeatureList::IsEnabled(
+ blink::features::kPopupWidgetRequiresUserActivation) &&
+ !owner_->UpdateUserActivationState(
+ blink::mojom::UserActivationUpdateType::kConsumeTransientActivation,
+ blink::mojom::UserActivationNotificationType::kNone)) {
+ return;
+ }
+
// We still need to allocate a widget routing id. Even though the renderer
// doesn't receive it, the browser side still uses routing ids to track
// widgets in various global tables.
diff --git a/third_party/blink/renderer/core/exported/web_view_impl.cc b/third_party/blink/renderer/core/exported/web_view_impl.cc
index 726f53d..94d7922 100644
--- a/third_party/blink/renderer/core/exported/web_view_impl.cc
+++ b/third_party/blink/renderer/core/exported/web_view_impl.cc
@@ -1079,13 +1079,16 @@
WebPagePopupImpl* WebViewImpl::OpenPagePopup(PagePopupClient* client) {
CHECK(client);
- // This guarantees there is never more than 1 PagePopup active at a time.
- // CancelPagePopup may fire a synchronous change event when a select element
- // is closed, but that event handler shouldn't be able to open another picker
- // because it would require multiple user activations to be created and
- // consumed within the same task.
+ // Make sure there is never more than one page popup active at a time.
CancelPagePopup();
- CHECK(!page_popup_);
+ // CancelPagePopup may fire a synchronous change event when a select element
+ // is closed, which may then try to open another page popup. We prevent this
+ // through this early return. Note that `WebPagePopupImpl::Create` call below
+ // asynchronously prevents multiple popups with a single user activation
+ // anyway.
+ if (page_popup_) {
+ return nullptr;
+ }
LocalFrame* opener_frame = client->OwnerElement().GetDocument().GetFrame();
WebLocalFrameImpl* web_opener_frame =
diff --git a/third_party/blink/renderer/core/html/forms/color_input_type.cc b/third_party/blink/renderer/core/html/forms/color_input_type.cc
index 4e818a1b..2613dc7f 100644
--- a/third_party/blink/renderer/core/html/forms/color_input_type.cc
+++ b/third_party/blink/renderer/core/html/forms/color_input_type.cc
@@ -254,10 +254,12 @@
document.AddConsoleMessage(MakeGarbageCollected<ConsoleMessage>(
mojom::blink::ConsoleMessageSource::kJavaScript,
mojom::blink::ConsoleMessageLevel::kWarning,
- "A user gesture is required to show the color picker."));
+ "User activation is required to show the color picker."));
return;
}
- LocalFrame::ConsumeTransientUserActivation(document.GetFrame());
+ if (!RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+ LocalFrame::ConsumeTransientUserActivation(document.GetFrame());
+ }
ChromeClient* chrome_client = GetChromeClient();
if (chrome_client && !HasOpenedPopup()) {
@@ -268,6 +270,10 @@
OpenPopupView();
}
+ if (RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+ LocalFrame::ConsumeTransientUserActivation(document.GetFrame());
+ }
+
event.SetDefaultHandled();
}
diff --git a/third_party/blink/renderer/core/html/forms/html_input_element.cc b/third_party/blink/renderer/core/html/forms/html_input_element.cc
index eb55bd5..97429e3 100644
--- a/third_party/blink/renderer/core/html/forms/html_input_element.cc
+++ b/third_party/blink/renderer/core/html/forms/html_input_element.cc
@@ -2625,12 +2625,17 @@
if (!LocalFrame::HasTransientUserActivation(frame)) {
exception_state.ThrowDOMException(
DOMExceptionCode::kNotAllowedError,
- "HTMLInputElement::showPicker() requires a user gesture.");
+ "HTMLInputElement::showPicker() requires user activation.");
return;
}
- LocalFrame::ConsumeTransientUserActivation(frame);
+ if (!RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+ LocalFrame::ConsumeTransientUserActivation(frame);
+ }
input_type_view_->OpenPopupView();
+ if (RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+ LocalFrame::ConsumeTransientUserActivation(frame);
+ }
}
bool HTMLInputElement::IsPickerVisible() const {
diff --git a/third_party/blink/renderer/core/html/forms/html_select_element.cc b/third_party/blink/renderer/core/html/forms/html_select_element.cc
index ec67bfe..980eb65 100644
--- a/third_party/blink/renderer/core/html/forms/html_select_element.cc
+++ b/third_party/blink/renderer/core/html/forms/html_select_element.cc
@@ -1832,7 +1832,7 @@
if (!LocalFrame::HasTransientUserActivation(frame)) {
exception_state.ThrowDOMException(DOMExceptionCode::kNotAllowedError,
- "showPicker() requires a user gesture.");
+ "showPicker() requires user activation.");
return;
}
@@ -1845,9 +1845,13 @@
return;
}
- LocalFrame::ConsumeTransientUserActivation(frame);
-
+ if (!RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+ LocalFrame::ConsumeTransientUserActivation(frame);
+ }
select_type_->ShowPicker();
+ if (RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+ LocalFrame::ConsumeTransientUserActivation(frame);
+ }
}
bool HTMLSelectElement::IsValidBuiltinCommand(HTMLElement& invoker,
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index 91508ef..d0e06909 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -4855,6 +4855,15 @@
status: "stable",
},
{
+ // Kill switch for requiring and consuming transient user activation in
+ // the browser process when creating a form-control popup widget,
+ // guarding against compromised renderers opening popups without a user
+ // gesture.
+ // See https://crbug.com/506152584.
+ name: "PopupWidgetRequiresUserActivation",
+ status: "stable",
+ },
+ {
name: "PositionOutsideTabSpanCheckSiblingNode",
status: "stable",
},
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 091c9b8..46ccfd3 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -59,6 +59,8 @@
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/file_select_listener.h"
#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/render_widget_host.h"
+#include "content/public/browser/render_widget_host_iterator.h"
#include "content/public/browser/site_isolation_policy.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/common/bindings_policy.h"
@@ -136,7 +138,9 @@
#include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
#include "third_party/blink/public/mojom/frame/remote_frame.mojom-test-utils.h"
#include "third_party/blink/public/mojom/loader/mixed_content.mojom.h"
+#include "third_party/blink/public/mojom/page/widget.mojom.h"
#include "third_party/blink/public/mojom/permissions/permission.mojom.h"
+#include "third_party/blink/public/mojom/widget/platform_widget.mojom.h"
using ::testing::HasSubstr;
using ::testing::Optional;
@@ -4586,6 +4590,57 @@
kill_waiter.Wait());
}
+// CreateNewPopupWidget should be ignored when the requesting frame has no
+// transient user activation, since opening a form-control popup is gated on
+// user activation in the renderer and the browser-side state mirrors that.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ CreateNewPopupWidgetRequiresUserActivation) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryFrameTree()
+ .root();
+ RenderFrameHostImpl* rfh = root->current_frame_host();
+
+ ASSERT_FALSE(rfh->HasTransientUserActivation());
+
+ auto count_popup_widgets = [&]() {
+ size_t count = 0;
+ std::unique_ptr<RenderWidgetHostIterator> widgets =
+ RenderWidgetHost::GetRenderWidgetHosts();
+ while (RenderWidgetHost* widget = widgets->GetNextHost()) {
+ if (widget->GetPopupCreatorFrameId() == rfh->GetGlobalId()) {
+ ++count;
+ }
+ }
+ return count;
+ };
+
+ auto request_popup_widget = [&]() {
+ mojo::AssociatedRemote<blink::mojom::Widget> widget;
+ std::ignore = widget.BindNewEndpointAndPassDedicatedReceiver();
+ static_cast<blink::mojom::LocalFrameHost*>(rfh)->CreateNewPopupWidget(
+ mojo::AssociatedRemote<blink::mojom::PopupWidgetHost>()
+ .BindNewEndpointAndPassDedicatedReceiver(),
+ mojo::AssociatedRemote<blink::mojom::WidgetHost>()
+ .BindNewEndpointAndPassDedicatedReceiver(),
+ widget.Unbind());
+ };
+
+ // The frame has never received a user gesture, so the request should be
+ // dropped without creating a popup widget.
+ request_popup_widget();
+ EXPECT_EQ(0u, count_popup_widgets());
+
+ // With transient user activation the request should be honoured.
+ EXPECT_TRUE(root->UpdateUserActivationState(
+ blink::mojom::UserActivationUpdateType::kNotifyActivation,
+ blink::mojom::UserActivationNotificationType::kTest));
+ ASSERT_TRUE(rfh->HasTransientUserActivation());
+ request_popup_widget();
+ EXPECT_EQ(1u, count_popup_widgets());
+}
+
namespace {
// Interceptor that replaces the origin in the DidCommitProvisionalLoadParams
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 091c9b8..46ccfd3 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -59,6 +59,8 @@
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/file_select_listener.h"
#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/render_widget_host.h"
+#include "content/public/browser/render_widget_host_iterator.h"
#include "content/public/browser/site_isolation_policy.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/common/bindings_policy.h"
@@ -136,7 +138,9 @@
#include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
#include "third_party/blink/public/mojom/frame/remote_frame.mojom-test-utils.h"
#include "third_party/blink/public/mojom/loader/mixed_content.mojom.h"
+#include "third_party/blink/public/mojom/page/widget.mojom.h"
#include "third_party/blink/public/mojom/permissions/permission.mojom.h"
+#include "third_party/blink/public/mojom/widget/platform_widget.mojom.h"
using ::testing::HasSubstr;
using ::testing::Optional;
@@ -4586,6 +4590,57 @@
kill_waiter.Wait());
}
+// CreateNewPopupWidget should be ignored when the requesting frame has no
+// transient user activation, since opening a form-control popup is gated on
+// user activation in the renderer and the browser-side state mirrors that.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ CreateNewPopupWidgetRequiresUserActivation) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryFrameTree()
+ .root();
+ RenderFrameHostImpl* rfh = root->current_frame_host();
+
+ ASSERT_FALSE(rfh->HasTransientUserActivation());
+
+ auto count_popup_widgets = [&]() {
+ size_t count = 0;
+ std::unique_ptr<RenderWidgetHostIterator> widgets =
+ RenderWidgetHost::GetRenderWidgetHosts();
+ while (RenderWidgetHost* widget = widgets->GetNextHost()) {
+ if (widget->GetPopupCreatorFrameId() == rfh->GetGlobalId()) {
+ ++count;
+ }
+ }
+ return count;
+ };
+
+ auto request_popup_widget = [&]() {
+ mojo::AssociatedRemote<blink::mojom::Widget> widget;
+ std::ignore = widget.BindNewEndpointAndPassDedicatedReceiver();
+ static_cast<blink::mojom::LocalFrameHost*>(rfh)->CreateNewPopupWidget(
+ mojo::AssociatedRemote<blink::mojom::PopupWidgetHost>()
+ .BindNewEndpointAndPassDedicatedReceiver(),
+ mojo::AssociatedRemote<blink::mojom::WidgetHost>()
+ .BindNewEndpointAndPassDedicatedReceiver(),
+ widget.Unbind());
+ };
+
+ // The frame has never received a user gesture, so the request should be
+ // dropped without creating a popup widget.
+ request_popup_widget();
+ EXPECT_EQ(0u, count_popup_widgets());
+
+ // With transient user activation the request should be honoured.
+ EXPECT_TRUE(root->UpdateUserActivationState(
+ blink::mojom::UserActivationUpdateType::kNotifyActivation,
+ blink::mojom::UserActivationNotificationType::kTest));
+ ASSERT_TRUE(rfh->HasTransientUserActivation());
+ request_popup_widget();
+ EXPECT_EQ(1u, count_popup_widgets());
+}
+
namespace {
// Interceptor that replaces the origin in the DidCommitProvisionalLoadParams
Original Bug Report
Cross-origin keystroke interception via OOPIF popup parenting on Aura
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A compromised cross-origin subframe (OOPIF) can potentially intercept keystrokes intended for the top-level main frame on Aura platforms. This occurs because the browser unconditionally parents popup widgets created by subframes to the outermost main frame’s view without origin checks. Consequently, the main frame’s input handler forwards all keyboard events to the attacker-controlled popup, bypassing Site Isolation.
Affected files:
content/browser/renderer_host/render_widget_host_view_event_handler.cccontent/browser/renderer_host/render_widget_host_view_aura.cccontent/browser/web_contents/web_contents_impl.cccontent/browser/renderer_host/render_frame_host_impl.cc
Estimated timestamp from git blame: 2024-04-26
Description
A logic flaw in how the browser process handles popup widget creation on Aura platforms (Windows, ChromeOS, Linux) can lead to a Site Isolation bypass. A compromised subframe renderer can create an invisible popup widget that intercepts all keystrokes typed into the cross-origin top-level parent page.
The vulnerability stems from the following chain of events:
- Missing Origin and Activation Checks: When a renderer sends the
blink.mojom.LocalFrameHost::CreateNewPopupWidgetIPC,RenderFrameHostImpl::CreateNewPopupWidgetonly verifies if the requesting frame is active. It does not enforce user activation (transient or sticky), nor does it check if the frame is a top-level main frame. - Cross-Origin Parenting: When the attacker subsequently calls
ShowPopupvia IPC,WebContentsImpl::ShowCreatedWidgetidentifies the parent view by callingGetOutermostWebContents()->GetRenderWidgetHostView(). This unconditionally retrieves the top-level main frame’s view.InitAsPopupis then called, parenting the attacker’s popup to the main frame without anySiteInstanceor origin boundary validation. - Bypassed Dismissal: The popup dismissal logic in
RenderWidgetHostViewAura::ApplyEventObserverForPopupExitexplicitly ignores clicks targeting the parent window (target != popup_parent_host_view_->window_evaluates tofalse). If the user clicks an input field in the main frame, the invisible popup remains active. - Unconditional Input Forwarding: When the user types into the main frame,
RenderWidgetHostViewEventHandler::OnKeyEventchecksNeedsInputGrab(popup_child_host_view_), which returns true forkPopupwidgets. The browser unconditionally forwards theui::KeyEventto the popup’s event handler, routing the keystrokes to the attacker-controlled renderer process instead of the main frame.
Potential Reproduction Steps
Note: Our tooling agent does not have the ability to run code, so these are suggested steps based on static code analysis.
- An attacker compromises the renderer process of a cross-origin Site Isolated subframe (e.g.,
evil.comembedded invictim.com). - The compromised renderer uses the
blink.mojom.LocalFrameHostinterface to send aCreateNewPopupWidgetIPC to the browser process. - The attacker sends the
blink.mojom.PopupWidgetHost::ShowPopupIPC, specifying a 1x1 invisible geometry placed anywhere on the screen. - The victim clicks on a sensitive input field (e.g., a password field) in the top-level
victim.comframe. The click does not dismiss the popup because the dismissal logic ignores clicks on the parent window. - The victim types into the field. Every keystroke is routed by the browser’s input handler directly to the
evil.comrenderer process, allowing the attacker to exfiltrate the data.
Suggested Fix
There are a few potential avenues to address this:
- Restrict Popup Creation: Modify
RenderFrameHostImpl::CreateNewPopupWidgetto enforce strict user activation checks, or restrict it so that only top-level frames (or frames with specific permissions) can createkPopupwidgets. - Validate Origin During Parenting: In
WebContentsImpl::ShowCreatedWidget, verify that theSiteInstanceor origin of the frame requesting the popup matches theSiteInstanceof the view it is being parented to. If they do not match, the creation or parenting should be blocked. - Parent to the Requesting Frame: Instead of unconditionally using
GetOutermostWebContents()->GetRenderWidgetHostView(), parent the popup to the specificRenderWidgetHostViewof the frame that initiated the request, ensuring events are routed within the correct process boundaries.
Evaluated with Chrome root at commit: 3acbde3302da0cb19488c22c0eb007c791207b4b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.