CVE-2026-79204
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/popup_menu_helper_mac.mm |
modified | |
ifcontent/browser/renderer_host/render_widget_host_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/popup_menu_helper_mac.hcontent/browser/renderer_host/popup_menu_helper_mac.mmcontent/browser/renderer_host/render_widget_host_browsertest.cc
Patch
From cc4a1f34f2305fb96d7eaec3a026f7dd8a837dbe Mon Sep 17 00:00:00 2001 From: Maggie Chen <[email protected]> Date: Fri, 17 Jul 2026 10:43:39 -0700 Subject: [PATCH] [agy][content] Dismiss popup on permission prompt Mac-specific popup menus (like <select> dropdowns) run a native NSMenu nested run loop. During this loop, application tasks are still pumped, allowing a permission prompt to appear under the menu. This can lead to clickjacking. This CL starts a periodic timer in PopupMenuHelper to check if the menu anchor element intersects with any permission prompt. If it does, the menu is dismissed. Fixed: 514069975 Test: content_browsertests Change-Id: I374f651435a3ab7e53b6cc80752c8c8ca6442500 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8092080 Reviewed-by: Avi Drissman <[email protected]> Commit-Queue: Maggie Chen <[email protected]> Cr-Commit-Position: refs/heads/main@{#1664018} --- diff --git a/content/browser/renderer_host/popup_menu_helper_mac.h b/content/browser/renderer_host/popup_menu_helper_mac.h index 4a2a4eb..0f08652 100644 --- a/content/browser/renderer_host/popup_menu_helper_mac.h +++ b/content/browser/renderer_host/popup_menu_helper_mac.h @@ -11,6 +11,7 @@ #include "base/memory/raw_ptr.h" #include "base/memory/weak_ptr.h" #include "base/scoped_observation.h" +#include "base/timer/timer.h" #include "content/common/content_export.h" #include "content/common/render_widget_host_ns_view.mojom.h" #include "content/public/browser/render_widget_host.h" @@ -66,6 +67,14 @@ RenderWidgetHostViewMac* GetRootRenderWidgetHostView() const; + // Returns true if the menu, as positioned at |bounds_in_screen|, would + // overlap a permission prompt that is currently showing. + bool IntersectsPermissionPrompt(const gfx::Rect& bounds_in_screen) const; + + // Periodically called while the menu is open to ensure it does not occlude a + // permission prompt that appeared after the menu was opened. + void CheckPermissionPromptOcclusion(); + raw_ptr<Delegate> delegate_; // Weak. Owns |this|. base::ScopedObservation<RenderWidgetHost, RenderWidgetHostObserver> @@ -75,6 +84,10 @@ bool popup_was_hidden_ = false; + // Screen bounds of the anchor element while the menu is open. + gfx::Rect anchor_bounds_in_screen; + base::RepeatingTimer occlusion_check_timer_; + mojo::Remote<remote_cocoa::mojom::PopupMenuRunner> remote_runner_; base::WeakPtrFactory<PopupMenuHelper> weak_ptr_factory_{this}; diff --git a/content/browser/renderer_host/popup_menu_helper_mac.mm b/content/browser/renderer_host/popup_menu_helper_mac.mm index 3b76623..cf7b9977 100644 --- a/content/browser/renderer_host/popup_menu_helper_mac.mm +++ b/content/browser/renderer_host/popup_menu_helper_mac.mm @@ -22,6 +22,10 @@ bool g_allow_showing_popup_menus = true; +// Interval at which to re-evaluate whether the open menu overlaps a +// permission prompt that may have appeared after the menu was opened. +constexpr base::TimeDelta kOcclusionCheckInterval = base::Milliseconds(100); + } // namespace PopupMenuHelper::PopupMenuHelper( @@ -61,22 +65,26 @@ // Convert element_bounds to be in screen. gfx::Rect client_area = web_contents->GetContainerBounds(); - gfx::Rect bounds_in_screen = bounds + client_area.OffsetFromOrigin(); + anchor_bounds_in_screen = bounds + client_area.OffsetFromOrigin(); // The new popup menu would overlap the permission prompt, which could lead to // users making decisions based on incorrect information. We should close the // popup if it intersects with the permission prompt. - auto permission_exclusion_area_bounds = - PermissionControllerImpl::FromBrowserContext( - web_contents->GetBrowserContext()) - ->GetExclusionAreaBoundsInScreen(web_contents); - if (permission_exclusion_area_bounds && - permission_exclusion_area_bounds->Intersects(bounds_in_screen)) { + if (IntersectsPermissionPrompt(anchor_bounds_in_screen)) { popup_client_->DidCancel(); delegate_->OnMenuClosed(); // May delete |this|. return; } + // The native menu runs a nested run loop in which application tasks are + // pumped, so a permission prompt may appear after the menu has opened. + // Re-evaluate periodically and dismiss the menu if it would overlap. The + // timer must be started before DisplayPopupMenu(), which may run the nested + // loop synchronously. See https://crbug.com/514069975 + occlusion_check_timer_.Start( + FROM_HERE, kOcclusionCheckInterval, this, + &PopupMenuHelper::CheckPermissionPromptOcclusion); + remote_runner_.reset(); rwhvm->GetNSView()->DisplayPopupMenu( remote_cocoa::mojom::PopupMenu::New( @@ -87,6 +95,7 @@ } void PopupMenuHelper::Hide() { + occlusion_check_timer_.Stop(); if (remote_runner_) { remote_runner_->Hide(); } @@ -94,6 +103,35 @@ popup_client_.reset(); } +bool PopupMenuHelper::IntersectsPermissionPrompt( + const gfx::Rect& bounds_in_screen) const { + if (!render_frame_host_) { + return false; + } + RenderWidgetHostViewMac* rwhvm = GetRootRenderWidgetHostView(); + if (!rwhvm) { + return false; + } + auto* web_contents = rwhvm->GetWebContents(); + auto permission_exclusion_area_bounds = + PermissionControllerImpl::FromBrowserContext( + web_contents->GetBrowserContext()) + ->GetExclusionAreaBoundsInScreen(web_contents); + return permission_exclusion_area_bounds && + permission_exclusion_area_bounds->Intersects(bounds_in_screen); +} + +void PopupMenuHelper::CheckPermissionPromptOcclusion() { + if (popup_was_hidden_ || + !IntersectsPermissionPrompt(anchor_bounds_in_screen)) { + return; + } + if (popup_client_) { + popup_client_->DidCancel(); + } + Hide(); +} + RenderWidgetHostViewMac* PopupMenuHelper::GetRootRenderWidgetHostView() const { RenderWidgetHostViewBase* root_view = render_frame_host_->GetView()->GetRootView(); @@ -114,6 +152,8 @@ } void PopupMenuHelper::PopupMenuClosed(std::optional<uint32_t> selected_item) { + occlusion_check_timer_.Stop(); + // The RenderFrameHost may be deleted while running the menu, or it may have // requested the close. Don't notify in these cases. if (popup_client_ && !popup_was_hidden_) { diff --git a/content/browser/renderer_host/render_widget_host_browsertest.cc b/content/browser/renderer_host/render_widget_host_browsertest.cc index 8182ccf..254c10b6 100644 --- a/content/browser/renderer_host/render_widget_host_browsertest.cc +++ b/content/browser/renderer_host/render_widget_host_browsertest.cc @@ -772,8 +772,10 @@ public blink::mojom::PopupMenuClient { public: explicit ShowPopupMenuInterceptor(RenderFrameHostImpl* render_frame_host, - const gfx::Rect& overriden_bounds) + const gfx::Rect& overriden_bounds, + base::OnceClosure task_in_nested_loop = {}) : overriden_bounds_(overriden_bounds), + task_in_nested_loop_(std::move(task_in_nested_loop)), swapped_impl_( render_frame_host->local_frame_host_receiver_for_testing(), this) {} @@ -795,10 +797,18 @@ bool right_aligned, bool allow_multiple_selection) override { CHECK(GetForwardingInterface()); + // If supplied, post a task that will run inside the menu's nested event + // loop after the menu is opened by the synchronous call below. + if (task_in_nested_loop_) { + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, std::move(task_in_nested_loop_)); + } GetForwardingInterface()->ShowPopupMenu( receiver_.BindNewPipeAndPassRemote(), overriden_bounds_, font_size, selected_item, std::move(menu_items), right_aligned, allow_multiple_selection); + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, run_loop_.QuitClosure()); } void DidAcceptIndices(const std::vector<int32_t>& indices) override { @@ -808,7 +818,6 @@ void DidCancel() override {
Regression Test / PoC
diff --git a/content/browser/renderer_host/render_widget_host_browsertest.cc b/content/browser/renderer_host/render_widget_host_browsertest.cc
index 8182ccf..254c10b6 100644
--- a/content/browser/renderer_host/render_widget_host_browsertest.cc
+++ b/content/browser/renderer_host/render_widget_host_browsertest.cc
@@ -772,8 +772,10 @@
public blink::mojom::PopupMenuClient {
public:
explicit ShowPopupMenuInterceptor(RenderFrameHostImpl* render_frame_host,
- const gfx::Rect& overriden_bounds)
+ const gfx::Rect& overriden_bounds,
+ base::OnceClosure task_in_nested_loop = {})
: overriden_bounds_(overriden_bounds),
+ task_in_nested_loop_(std::move(task_in_nested_loop)),
swapped_impl_(
render_frame_host->local_frame_host_receiver_for_testing(),
this) {}
@@ -795,10 +797,18 @@
bool right_aligned,
bool allow_multiple_selection) override {
CHECK(GetForwardingInterface());
+ // If supplied, post a task that will run inside the menu's nested event
+ // loop after the menu is opened by the synchronous call below.
+ if (task_in_nested_loop_) {
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, std::move(task_in_nested_loop_));
+ }
GetForwardingInterface()->ShowPopupMenu(
receiver_.BindNewPipeAndPassRemote(), overriden_bounds_, font_size,
selected_item, std::move(menu_items), right_aligned,
allow_multiple_selection);
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, run_loop_.QuitClosure());
}
void DidAcceptIndices(const std::vector<int32_t>& indices) override {
@@ -808,7 +818,6 @@
void DidCancel() override {
is_cancelled_ = true;
receiver_.reset();
- run_loop_.Quit();
}
bool is_cancelled() const { return is_cancelled_; }
@@ -817,6 +826,7 @@
base::RunLoop run_loop_;
bool is_cancelled_{false};
gfx::Rect overriden_bounds_;
+ base::OnceClosure task_in_nested_loop_;
mojo::test::ScopedSwapImplForTesting<blink::mojom::LocalFrameHost>
swapped_impl_;
mojo::Receiver<blink::mojom::PopupMenuClient> receiver_{this};
@@ -871,6 +881,50 @@
#endif // BUILDFLAG(IS_MAC)
}
+#if BUILDFLAG(IS_MAC)
+// Variant of the above where no permission prompt is showing when the popup
+// opens, but one appears while the native menu's nested run loop is active.
+// The browser should detect the new prompt and dismiss the open popup.
+IN_PROC_BROWSER_TEST_F(RenderWidgetHostSitePerProcessTest,
+ BrowserClosesOpenPopupWhenPermissionPromptAppears) {
+ GURL main_url(embedded_test_server()->GetURL(
+ "a.com", "/site_isolation/page-with-select.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ SimulateEndOfPaintHoldingOnPrimaryMainFrame(shell()->web_contents());
+
+ auto* contents = static_cast<WebContentsImpl*>(web_contents());
+ FrameTreeNode* root = contents->GetPrimaryFrameTree().root();
+ RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+ auto* permission_controller = static_cast<PermissionControllerImpl*>(
+ root_frame_host->GetBrowserContext()->GetPermissionController());
+
+ gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+ // Simulate a permission prompt being shown only after the popup has opened
+ // and entered its nested run loop.
+ ShowPopupMenuInterceptor show_popup_menu_interceptor(
+ root_frame_host,
+ permission_exclusion_area_bounds -
+ contents->GetContainerBounds().OffsetFromOrigin(),
+ base::BindLambdaForTesting([&]() {
+ permission_controller->set_exclusion_area_bounds_for_tests(
+ permission_exclusion_area_bounds);
+ }));
+
+ input::NativeWebKeyboardEvent event(
+ blink::WebKeyboardEvent::Type::kChar, blink::WebInputEvent::kNoModifiers,
+ blink::WebInputEvent::GetStaticTimeStampForTests());
+ event.text[0] = ' ';
+ EXPECT_TRUE(ExecJs(root_frame_host, "focusSelectMenu();"));
+ root_frame_host->GetRenderWidgetHost()->ForwardKeyboardEvent(event);
+
+ show_popup_menu_interceptor.Wait();
+ EXPECT_TRUE(show_popup_menu_interceptor.is_cancelled());
+
+ permission_controller->set_exclusion_area_bounds_for_tests(std::nullopt);
+}
+#endif // BUILDFLAG(IS_MAC)
+
#endif // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
// Tests that `window.screen` dimensions match the display, not the viewport,
Original Bug Report
Potential Clickjacking in macOS <select> menus via permission prompt occlusion
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 clickjacking vulnerability on macOS allows a malicious site to occlude permission prompts with native <select> menus and bypass click-protection. By programmatically dismissing the menu after the protection delay has expired, an attacker can hijack a user’s click to grant unauthorized permissions.
Affected files:
content/browser/renderer_host/popup_menu_helper_mac.mmcontent/app_shim_remote_cocoa/render_widget_host_ns_view_bridge.mmui/views/windows_stationarity_monitor_mac.mmcomponents/permissions/permission_request_manager.cc
Estimated timestamp from git blame: 2023-07-19
Description
On macOS, the native <select> popup menu implementation in PopupMenuHelperMac appears vulnerable to a clickjacking attack that bypasses the InputEventActivationProtector. This protection is intended to prevent users from accidentally clicking ‘Allow’ on permission prompts by ignoring inputs for a short period (typically 500ms) after the prompt becomes visible or when the window state changes.
The vulnerability exists due to a combination of factors:
- One-shot Exclusion Check:
PopupMenuHelperMac::ShowPopupMenuchecks for permission prompt exclusion areas (viaPermissionControllerImpl::GetExclusionAreaBoundsInScreen) only once before the menu is displayed. It does not monitor for new prompts appearing after the menu is open. - Mojo Task Pumping in Nested Loop: In
content/app_shim_remote_cocoa/render_widget_host_ns_view_bridge.mm, the use ofScopedAllowApplicationTasksInNativeNestedLoopallows Mojo tasks to be processed while the nativeNSMenunested event loop is running. This allows a malicious page to programmatically trigger a permission request (e.g., Geolocation) while the<select>menu is already visible. - Prompt Occlusion: The resulting permission prompt is created underneath the existing native menu window (
NSPopUpMenuWindow), which resides at a higher window level. TheInputEventActivationProtectoris armed when the bubble appears, but its safety timer begins immediately while the prompt is occluded. - Silent Protection Expiry: An attacker can keep the menu open for longer than 500ms until the protector expires. They can then programmatically remove the
<select>element from the DOM, causing the menu to be dismissed instantly viacancelTrackingWithoutAnimationinWebMenuRunner. - Lack of Re-arming:
WindowsStationarityMonitorMac(ui/views/windows_stationarity_monitor_mac.mm) only observesviews::Widgetinstances and does not track native AppKit menu windows. Consequently, when the menu vanishes, theInputEventActivationProtectoris not notified to reset its protection timestamp. A subsequent user click, intended for the menu, is processed by the newly revealed ‘Allow’ button without being blocked.
Potential Reproduction Steps
Note: These are potential steps based on code analysis; a functional proof of concept has not yet been executed.
- On macOS, navigate to a site with a
<select>element and click it to open the native menu. - While the menu is open, the page executes a permission-requesting API (e.g.,
navigator.geolocation.getCurrentPosition()). - The page waits for approximately 600ms (to exceed the double-click interval).
- The page removes the
<select>element from the DOM to instantly dismiss the native menu. - The user’s next click is registered by the ‘Allow’ button on the revealed permission prompt, as the input protection has already expired.
Suggested Fix
- Modify
PopupMenuHelperMacto listen for new permission prompts appearing while the menu is open and close the menu if an intersection occurs. - Update
WindowsStationarityMonitorMacto detect the presence and dismissal of native AppKit menus, ensuring theInputEventActivationProtectoris re-armed whenever a native menu is dismissed.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.