Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Input
DescriptionUI misrepresentation in Input
ComponentInput
Bug ClassLogic Error
Tracker514069975
Fix commitcc4a1f34f230 (chromium/src) +116/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/popup_menu_helper_mac.mm
modified
if
content/browser/renderer_host/render_widget_host_browsertest.cc
modified

Files Changed

  • content/browser/renderer_host/popup_menu_helper_mac.h
  • content/browser/renderer_host/popup_menu_helper_mac.mm
  • content/browser/renderer_host/render_widget_host_browsertest.cc
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 {
Loading diff…

Regression Test / PoC

shipped with the fix
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,
Loading diff…

Original Bug Report

reported by [email protected]

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.mm
  • content/app_shim_remote_cocoa/render_widget_host_ns_view_bridge.mm
  • ui/views/windows_stationarity_monitor_mac.mm
  • components/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:

  1. One-shot Exclusion Check: PopupMenuHelperMac::ShowPopupMenu checks for permission prompt exclusion areas (via PermissionControllerImpl::GetExclusionAreaBoundsInScreen) only once before the menu is displayed. It does not monitor for new prompts appearing after the menu is open.
  2. Mojo Task Pumping in Nested Loop: In content/app_shim_remote_cocoa/render_widget_host_ns_view_bridge.mm, the use of ScopedAllowApplicationTasksInNativeNestedLoop allows Mojo tasks to be processed while the native NSMenu nested 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.
  3. Prompt Occlusion: The resulting permission prompt is created underneath the existing native menu window (NSPopUpMenuWindow), which resides at a higher window level. The InputEventActivationProtector is armed when the bubble appears, but its safety timer begins immediately while the prompt is occluded.
  4. 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 via cancelTrackingWithoutAnimation in WebMenuRunner.
  5. Lack of Re-arming: WindowsStationarityMonitorMac (ui/views/windows_stationarity_monitor_mac.mm) only observes views::Widget instances and does not track native AppKit menu windows. Consequently, when the menu vanishes, the InputEventActivationProtector is 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.

  1. On macOS, navigate to a site with a <select> element and click it to open the native menu.
  2. While the menu is open, the page executes a permission-requesting API (e.g., navigator.geolocation.getCurrentPosition()).
  3. The page waits for approximately 600ms (to exceed the double-click interval).
  4. The page removes the <select> element from the DOM to instantly dismiss the native menu.
  5. 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

  1. Modify PopupMenuHelperMac to listen for new permission prompts appearing while the menu is open and close the menu if an intersection occurs.
  2. Update WindowsStationarityMonitorMac to detect the presence and dismissal of native AppKit menus, ensuring the InputEventActivationProtector is 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.

View on issue tracker