Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Core
DescriptionUse after free in Core
ComponentCore
Sub-componentInternals>Core
Bug ClassUAF
Tracker514016335
Fix commit078db04964d4 (chromium/src) +293/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
content/browser/web_contents/web_contents_impl.cc
modified
ShowPopupInterceptor
content/browser/renderer_host/render_widget_host_browsertest.cc
modified
overriden_bounds_
content/browser/renderer_host/render_widget_host_browsertest.cc
modified
overriden_anchor_rect_
content/browser/renderer_host/render_widget_host_browsertest.cc
modified

Files Changed

  • content/browser/renderer_host/render_widget_host_browsertest.cc
  • content/browser/web_contents/web_contents_impl.cc
From 078db04964d4107fbcd2ef8d39e526f53849fb40 Mon Sep 17 00:00:00 2001
From: Kartar Singh <[email protected]>
Date: Thu, 24 Sep 2026 01:48:57 -0700
Subject: [PATCH] Validate popup anchor bounds against permission exclusion areas

When displaying popups, WebContentsImpl::ShowCreatedWidget validates
that popup bounds do not intersect active permission prompt exclusion
areas. Previously, this check only evaluated the popup's initial screen
bounds, omitting checks on the associated anchor rectangle. On platforms
where popup positioning is determined relative to the anchor rect (such
as Wayland on Aura), popups could still be positioned within the
exclusion area.

This update extends the validation in ShowCreatedWidget on Aura to also
check the anchor rectangle and its derived placement bounds against the
permission exclusion area, normalizing empty geometries to ensure
consistent intersection checking:
- If the popup bounds, anchor bounds, or default effective placement
  bounds intersect the exclusion area, the popup widget is destroyed.
- If only the vertically flipped placement bounds intersect the
  exclusion area, the popup is suppressed by returning early without
  destroying the widget.

Browser tests are added to verify that popups are blocked when the
anchor rect, effective bounds, flipped bounds, or empty anchor points
intersect the permission prompt, while popups outside the prompt remain
allowed.

TAG=agy

Bug: 514016335
Change-Id: Iddfa9df4f9b91a45b0288debe3843c603622cde2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8399549
Reviewed-by: Avi Drissman <[email protected]>
Reviewed-by: Joey Arhar <[email protected]>
Commit-Queue: Kartar Singh <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1704138}
---
diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc
index 46018a4..c379922 100644
--- a/content/browser/web_contents/web_contents_impl.cc
+++ b/content/browser/web_contents/web_contents_impl.cc
@@ -38,6 +38,7 @@
 #include "base/metrics/histogram_macros.h"
 #include "base/metrics/user_metrics.h"
 #include "base/no_destructor.h"
+#include "base/numerics/clamped_math.h"
 #include "base/observer_list.h"
 #include "base/process/process.h"
 #include "base/strings/strcat.h"
@@ -1340,6 +1341,74 @@
   }
 }
 
+// Result of checking popup and anchor bounds against the permission exclusion
+// area.
+enum class PopupExclusionAreaIntersection {
+  // Neither popup bounds, anchor bounds, nor effective placements intersect.
+  kIntersectsNone,
+  // The popup's initial rect, anchor rect, or unflipped effective placement
+  // intersects the permission exclusion area.
+  kIntersectsDirect,
+  // Only the vertically flipped effective placement intersects the permission
+  // exclusion area.
+  kIntersectsFlippedAnchor,
+};
+
+PopupExclusionAreaIntersection CheckPopupPermissionExclusionAreaIntersection(
+    const gfx::Rect& exclusion_area_bounds,
+    const gfx::Rect& popup_rect,
+    const gfx::Rect& anchor_rect) {
+  gfx::Rect normalized_rect = popup_rect;
+  if (normalized_rect.IsEmpty()) {
+    normalized_rect.set_size({std::max(1, normalized_rect.width()),
+                              std::max(1, normalized_rect.height())});
+  }
+
+  if (exclusion_area_bounds.Intersects(normalized_rect)) {
+    return PopupExclusionAreaIntersection::kIntersectsDirect;
+  }
+
+  // `anchor_rect`-based popup positioning (and vertical flipping via
+  // `kOwnedWindowAnchor`) is only used by
+  // `RenderWidgetHostViewAura::InitAsPopup`. Non-Aura platforms (e.g. macOS)
+  // ignore `anchor_rect` and position popups solely using `popup_rect`.
+#if defined(USE_AURA)
+  gfx::Rect normalized_anchor = anchor_rect;
+  if (normalized_anchor.IsEmpty()) {
+    normalized_anchor.set_size({std::max(1, normalized_anchor.width()),
+                                std::max(1, normalized_anchor.height())});
+  }
+
+  int popup_width = std::max(1, popup_rect.width());
+  int popup_height = std::max(1, popup_rect.height());
+
+  // Popups positioned using an anchor rect (such as on Wayland via
+  // xdg_positioner) use anchor_position=kBottomLeft, gravity=kBottomRight,
+  // and vertical flip constraint adjustment. Validate both unflipped and
+  // flipped placement bounds as well as anchor bounds against the exclusion
+  // area.
+  gfx::Rect anchor_effective_rect(
+      normalized_anchor.x(),
+      base::ClampAdd(normalized_anchor.y(), normalized_anchor.height()),
+      popup_width, popup_height);
+  gfx::Rect anchor_flipped_effective_rect(
+      normalized_anchor.x(),
+      base::ClampSub(normalized_anchor.y(), popup_height), popup_width,
+      popup_height);
+
+  if (exclusion_area_bounds.Intersects(normalized_anchor) ||
+      exclusion_area_bounds.Intersects(anchor_effective_rect)) {
+    return PopupExclusionAreaIntersection::kIntersectsDirect;
+  }
+
+  if (exclusion_area_bounds.Intersects(anchor_flipped_effective_rect)) {
+    return PopupExclusionAreaIntersection::kIntersectsFlippedAnchor;
+  }
+#endif  // defined(USE_AURA)
+
+  return PopupExclusionAreaIntersection::kIntersectsNone;
+}
+
 }  // namespace
 
 WebContentsImpl::WebContentsImpl(BrowserContext* browser_context)
@@ -6196,10 +6265,18 @@
   auto permission_exclusion_area_bounds =
       PermissionControllerImpl::FromBrowserContext(GetBrowserContext())
           ->GetExclusionAreaBoundsInScreen(outermost_web_contents);
-  if (permission_exclusion_area_bounds &&
-      permission_exclusion_area_bounds->Intersects(transformed_rect)) {
-    render_widget_host_impl->ShutdownAndDestroyWidget(true);
-    return;
+  if (permission_exclusion_area_bounds) {
+    switch (CheckPopupPermissionExclusionAreaIntersection(
+        *permission_exclusion_area_bounds, transformed_rect,
+        transformed_anchor_rect)) {
+      case PopupExclusionAreaIntersection::kIntersectsDirect:
+        render_widget_host_impl->ShutdownAndDestroyWidget(true);
+        return;
+      case PopupExclusionAreaIntersection::kIntersectsFlippedAnchor:
+        return;
+      case PopupExclusionAreaIntersection::kIntersectsNone:
+        break;
+    }
   }
 
   widget_host_view->InitAsPopup(view, transformed_rect,
diff --git a/content/browser/renderer_host/render_widget_host_browsertest.cc b/content/browser/renderer_host/render_widget_host_browsertest.cc
index 9efa1915..3686b268 100644
--- a/content/browser/renderer_host/render_widget_host_browsertest.cc
+++ b/content/browser/renderer_host/render_widget_host_browsertest.cc
@@ -4,6 +4,7 @@
 
 #include <algorithm>
 #include <memory>
+#include <optional>
 #include <vector>
 
 #include "base/functional/bind.h"
@@ -912,14 +913,17 @@
 class ShowPopupInterceptor
     : public blink::mojom::PopupWidgetHostInterceptorForTesting {
  public:
-  ShowPopupInterceptor(WebContentsImpl* web_contents,
-                       RenderFrameHostImpl* frame_host,
-                       const gfx::Rect& overriden_bounds)
+  ShowPopupInterceptor(
+      WebContentsImpl* web_contents,
+      RenderFrameHostImpl* frame_host,
+      const gfx::Rect& overriden_bounds,
+      const std::optional<gfx::Rect>& overriden_anchor_rect = std::nullopt)
       : create_new_popup_widget_interceptor_(
             frame_host,
             base::BindOnce(&ShowPopupInterceptor::DidCreatePopupWidget,
                            base::Unretained(this))),
-        overriden_bounds_(overriden_bounds) {}
+        overriden_bounds_(overriden_bounds),
+        overriden_anchor_rect_(overriden_anchor_rect) {}
 
   ShowPopupInterceptor(const ShowPopupInterceptor&) = delete;
   ShowPopupInterceptor& operator=(const ShowPopupInterceptor&) = delete;
@@ -943,8 +947,9 @@
   void ShowPopup(const gfx::Rect& initial_rect,
                  const gfx::Rect& initial_anchor_rect,
                  ShowPopupCallback callback) override {
-    GetForwardingInterface()->ShowPopup(overriden_bounds_, initial_anchor_rect,
-                                        std::move(callback));
+    GetForwardingInterface()->ShowPopup(
+        overriden_bounds_, overriden_anchor_rect_.value_or(initial_anchor_rect),
+        std::move(callback));
     run_loop_.Quit();
   }
 
@@ -962,6 +967,7 @@
   CreateNewPopupWidgetInterceptor create_new_popup_widget_interceptor_;
   base::RunLoop run_loop_;
   gfx::Rect overriden_bounds_;
+  std::optional<gfx::Rect> overriden_anchor_rect_;
   int32_t routing_id_ = IPC::mojom::kRoutingIdNone;
   int32_t process_id_ = 0;
 };
@@ -1084,6 +1090,206 @@
 #endif  // BUILDFLAG(IS_MAC)
 }
 
+// `anchor_rect`-based popup positioning (and vertical flipping via
+// `kOwnedWindowAnchor`) is only used by
+// `RenderWidgetHostViewAura::InitAsPopup`. Non-Aura platforms (e.g. macOS)
+// ignore `anchor_rect` and position popups solely using `initial_rect` (or
+// native menus via `ShowPopupMenu` for
+// `<select>`).
+#if defined(USE_AURA)
+IN_PROC_BROWSER_TEST_F(
+    RenderWidgetHostSitePerProcessTest,
+    BrowserDoesNotShowPopup_AnchorIntersectsPermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds are outside the exclusion area, but the anchor rect
+  // intersects it.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect intersecting_anchor_rect = permission_exclusion_area_bounds;
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      intersecting_anchor_rect);
+
+  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_interceptor.Wait();
+  EXPECT_FALSE(
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id()));
+}
+
+IN_PROC_BROWSER_TEST_F(
+    RenderWidgetHostSitePerProcessTest,
+    BrowserDoesNotShowPopup_EffectiveAnchorBoundsIntersectsPermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds and anchor rect individually do not intersect the exclusion
+  // area, but the effective popup rectangle positioned below the anchor rect
+  // intersects it.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect anchor_rect_above(100, 50, 100, 20);
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      anchor_rect_above);
+
+  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_interceptor.Wait();
+  EXPECT_FALSE(
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id()));
+}
+
+IN_PROC_BROWSER_TEST_F(
+    RenderWidgetHostSitePerProcessTest,
+    BrowserDoesNotShowPopup_FlippedAnchorBoundsIntersectsPermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds and anchor rect individually do not intersect the exclusion
+  // area, but the vertically flipped effective popup rectangle positioned
+  // above the anchor rect intersects it.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect anchor_rect_below(100, 250, 100, 20);
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      anchor_rect_below);
+
+  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_interceptor.Wait();
+  RenderWidgetHost* popup_widget_host =
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id());
+  ASSERT_TRUE(popup_widget_host);
+  EXPECT_FALSE(popup_widget_host->GetView()->IsShowing());
+}
+
+IN_PROC_BROWSER_TEST_F(
+    RenderWidgetHostSitePerProcessTest,
+    BrowserDoesNotShowPopup_EmptyAnchorIntersectsPermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds are outside the exclusion area, but an empty (0x0) anchor
+  // point is within the exclusion area.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect empty_anchor_point(150, 150, 0, 0);
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      empty_anchor_point);
+
+  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_interceptor.Wait();
+  EXPECT_FALSE(
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id()));
+}
+
+IN_PROC_BROWSER_TEST_F(RenderWidgetHostSitePerProcessTest,
+                       BrowserAllowsPopupOutsidePermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds and anchor bounds are both well outside the exclusion area.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect safe_anchor_bounds(900, 550, 200, 20);
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      safe_anchor_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_interceptor.Wait();
... (file diff truncated)
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 9efa1915..3686b268 100644
--- a/content/browser/renderer_host/render_widget_host_browsertest.cc
+++ b/content/browser/renderer_host/render_widget_host_browsertest.cc
@@ -4,6 +4,7 @@
 
 #include <algorithm>
 #include <memory>
+#include <optional>
 #include <vector>
 
 #include "base/functional/bind.h"
@@ -912,14 +913,17 @@
 class ShowPopupInterceptor
     : public blink::mojom::PopupWidgetHostInterceptorForTesting {
  public:
-  ShowPopupInterceptor(WebContentsImpl* web_contents,
-                       RenderFrameHostImpl* frame_host,
-                       const gfx::Rect& overriden_bounds)
+  ShowPopupInterceptor(
+      WebContentsImpl* web_contents,
+      RenderFrameHostImpl* frame_host,
+      const gfx::Rect& overriden_bounds,
+      const std::optional<gfx::Rect>& overriden_anchor_rect = std::nullopt)
       : create_new_popup_widget_interceptor_(
             frame_host,
             base::BindOnce(&ShowPopupInterceptor::DidCreatePopupWidget,
                            base::Unretained(this))),
-        overriden_bounds_(overriden_bounds) {}
+        overriden_bounds_(overriden_bounds),
+        overriden_anchor_rect_(overriden_anchor_rect) {}
 
   ShowPopupInterceptor(const ShowPopupInterceptor&) = delete;
   ShowPopupInterceptor& operator=(const ShowPopupInterceptor&) = delete;
@@ -943,8 +947,9 @@
   void ShowPopup(const gfx::Rect& initial_rect,
                  const gfx::Rect& initial_anchor_rect,
                  ShowPopupCallback callback) override {
-    GetForwardingInterface()->ShowPopup(overriden_bounds_, initial_anchor_rect,
-                                        std::move(callback));
+    GetForwardingInterface()->ShowPopup(
+        overriden_bounds_, overriden_anchor_rect_.value_or(initial_anchor_rect),
+        std::move(callback));
     run_loop_.Quit();
   }
 
@@ -962,6 +967,7 @@
   CreateNewPopupWidgetInterceptor create_new_popup_widget_interceptor_;
   base::RunLoop run_loop_;
   gfx::Rect overriden_bounds_;
+  std::optional<gfx::Rect> overriden_anchor_rect_;
   int32_t routing_id_ = IPC::mojom::kRoutingIdNone;
   int32_t process_id_ = 0;
 };
@@ -1084,6 +1090,206 @@
 #endif  // BUILDFLAG(IS_MAC)
 }
 
+// `anchor_rect`-based popup positioning (and vertical flipping via
+// `kOwnedWindowAnchor`) is only used by
+// `RenderWidgetHostViewAura::InitAsPopup`. Non-Aura platforms (e.g. macOS)
+// ignore `anchor_rect` and position popups solely using `initial_rect` (or
+// native menus via `ShowPopupMenu` for
+// `<select>`).
+#if defined(USE_AURA)
+IN_PROC_BROWSER_TEST_F(
+    RenderWidgetHostSitePerProcessTest,
+    BrowserDoesNotShowPopup_AnchorIntersectsPermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds are outside the exclusion area, but the anchor rect
+  // intersects it.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect intersecting_anchor_rect = permission_exclusion_area_bounds;
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      intersecting_anchor_rect);
+
+  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_interceptor.Wait();
+  EXPECT_FALSE(
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id()));
+}
+
+IN_PROC_BROWSER_TEST_F(
+    RenderWidgetHostSitePerProcessTest,
+    BrowserDoesNotShowPopup_EffectiveAnchorBoundsIntersectsPermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds and anchor rect individually do not intersect the exclusion
+  // area, but the effective popup rectangle positioned below the anchor rect
+  // intersects it.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect anchor_rect_above(100, 50, 100, 20);
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      anchor_rect_above);
+
+  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_interceptor.Wait();
+  EXPECT_FALSE(
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id()));
+}
+
+IN_PROC_BROWSER_TEST_F(
+    RenderWidgetHostSitePerProcessTest,
+    BrowserDoesNotShowPopup_FlippedAnchorBoundsIntersectsPermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds and anchor rect individually do not intersect the exclusion
+  // area, but the vertically flipped effective popup rectangle positioned
+  // above the anchor rect intersects it.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect anchor_rect_below(100, 250, 100, 20);
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      anchor_rect_below);
+
+  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_interceptor.Wait();
+  RenderWidgetHost* popup_widget_host =
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id());
+  ASSERT_TRUE(popup_widget_host);
+  EXPECT_FALSE(popup_widget_host->GetView()->IsShowing());
+}
+
+IN_PROC_BROWSER_TEST_F(
+    RenderWidgetHostSitePerProcessTest,
+    BrowserDoesNotShowPopup_EmptyAnchorIntersectsPermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds are outside the exclusion area, but an empty (0x0) anchor
+  // point is within the exclusion area.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect empty_anchor_point(150, 150, 0, 0);
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      empty_anchor_point);
+
+  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_interceptor.Wait();
+  EXPECT_FALSE(
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id()));
+}
+
+IN_PROC_BROWSER_TEST_F(RenderWidgetHostSitePerProcessTest,
+                       BrowserAllowsPopupOutsidePermissionPrompt) {
+  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* web_contents_impl = static_cast<WebContentsImpl*>(web_contents());
+  FrameTreeNode* root = web_contents_impl->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* root_frame_host = root->current_frame_host();
+
+  gfx::Rect permission_exclusion_area_bounds(100, 100, 100, 100);
+  static_cast<PermissionControllerImpl*>(
+      root_frame_host->GetBrowserContext()->GetPermissionController())
+      ->set_exclusion_area_bounds_for_tests(permission_exclusion_area_bounds);
+
+  // Initial bounds and anchor bounds are both well outside the exclusion area.
+  gfx::Rect safe_initial_bounds(900, 600, 200, 200);
+  gfx::Rect safe_anchor_bounds(900, 550, 200, 20);
+  ShowPopupInterceptor show_popup_interceptor(
+      web_contents_impl, root_frame_host, safe_initial_bounds,
+      safe_anchor_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_interceptor.Wait();
+  EXPECT_TRUE(
+      RenderWidgetHost::FromID(root_frame_host->GetProcess()->GetDeprecatedID(),
+                               show_popup_interceptor.last_routing_id()));
+}
+#endif  // defined(USE_AURA)
+
 #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.
Loading diff…

Original Bug Report

reported by [email protected]

Wayland popups can bypass permission prompt exclusion via unchecked anchor_rect

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 security check in Chromium designed to prevent popups from obscuring permission prompts can be bypassed on Linux/Wayland. The check validates the initial rectangle provided by the renderer but fails to validate the anchor rectangle, which is used by the Wayland compositor for final positioning.

Affected files:

  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/renderer_host/render_widget_host_view_aura.cc
  • ui/ozone/platform/wayland/host/xdg_popup.cc
  • ui/ozone/platform/wayland/host/wayland_popup.cc
  • ui/views/widget/desktop_aura/desktop_window_tree_host_platform.cc
  • ui/views/widget/desktop_aura/desktop_native_widget_aura.cc

Estimated timestamp from git blame: 2023-07-19

Summary

On Linux/Wayland, a compromised renderer can potentially bypass the security gate in WebContentsImpl::ShowCreatedWidget that prevents popups from overlapping sensitive UI elements like permission prompts. The vulnerability exists because the gate only validates the transformed_rect (derived from the renderer’s initial_rect) and fails to validate the transformed_anchor_rect. Since the Wayland compositor uses the anchor rectangle for positioning, an attacker can specify a benign initial_rect to pass the check and a malicious anchor_rect to position the popup over the exclusion area.

Technical Details

In content/browser/web_contents/web_contents_impl.cc, the browser implementation of ShowCreatedWidget contains logic to prevent new popups from obscuring permission prompts (lines 5923-5930). This check compares the proposed popup bounds against an exclusion area:

  auto permission_exclusion_area_bounds =
      PermissionControllerImpl::FromBrowserContext(GetBrowserContext())
          ->GetExclusionAreaBoundsInScreen(outermost_web_contents);
  if (permission_exclusion_area_bounds &&
      permission_exclusion_area_bounds->Intersects(transformed_rect)) {
    render_widget_host_impl->ShutdownAndDestroyWidget(true);
    return;
  }

However, it currently only considers the initial_rect provided by the renderer. On Wayland, the final positioning of a popup is handled by the compositor using the xdg_positioner interface. The renderer-supplied anchor_rect is propagated through the following chain:

  1. RenderWidgetHostViewAura::InitAsPopup stores the unchecked anchor_rect in the window’s kOwnedWindowAnchor property (line 471).
  2. DesktopNativeWidgetAura ensures this property is copied to the platform window’s initialization properties.
  3. On Wayland, WaylandPopup retrieves this via delegate()->GetOwnedWindowAnchorAndRectInDIP() (line 75).
  4. XdgPopup::CreatePositioner (in ui/ozone/platform/wayland/host/xdg_popup.cc) uses this anchor_rect via xdg_positioner_set_anchor_rect (line 273).
  5. Crucially, xdg_positioner_set_size is called using only the width and height of the bounds; the origin of the previously validated initial_rect is discarded by the compositor in favor of anchor-based positioning.

Potential Reproduction Steps

Note: These steps are theoretical as we have not executed code to confirm.

  1. On a Linux/Wayland environment, trigger a permission prompt to create an ’exclusion area'.
  2. From a compromised renderer, send a blink::mojom::PopupWidgetHost::ShowPopup IPC.
  3. Set initial_rect to a position that does not intersect the exclusion area (e.g., {x: 0, y: 0, w: 200, h: 200}).
  4. Set initial_anchor_rect to a position that targets the exclusion area (e.g., {x: prompt.x(), y: prompt.y() - 1, w: 1, h: 1}).
  5. The browser’s check will pass based on the initial_rect.
  6. The Wayland compositor will receive the malicious anchor_rect and position the popup relative to it, causing the popup to overlap the permission prompt.

Impact

This is a UI spoofing and clickjacking vulnerability. An attacker could potentially obscure or misrepresent permission prompts (e.g., for Camera, Microphone, or File System access), tricking users into granting sensitive permissions. This requires a compromised renderer and is specific to the Wayland platform on Linux.

Suggested Fix

Modify WebContentsImpl::ShowCreatedWidget to validate the transformed_anchor_rect against the permission_exclusion_area_bounds in addition to the transformed_rect. Alternatively, ensure that the final calculated bounds (if available at that stage) are checked.

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