Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Input
DescriptionInsufficient validation of untrusted input in Input
ComponentInput
Bug ClassLogic Error
Tracker516448843
Fix commit666763b5e735 (chromium/src) +123/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-16

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
modified

Files Changed

  • components/input/render_widget_host_input_event_router.cc
  • components/input/render_widget_targeter.cc
  • content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
From 666763b5e73565d41518a780617ad58e33a93a30 Mon Sep 17 00:00:00 2001
From: Aman Verma <[email protected]>
Date: Tue, 09 Jun 2026 07:52:41 -0700
Subject: [PATCH] input: Defend against coordinate spoofing in async hit-testing

A compromised parent renderer can spoof local coordinates in
asynchronous hit-test responses (returned via Mojo
`InputTargetClient::FrameSinkIdAt`) to redirect user click events to
unintended areas inside a child frame (clickjacking). Simple child
bounding-box checks are bypassed if the spoofed coordinates lie inside
the child frame's bounds.

To establish a zero-trust boundary, this CL implements validation in the
browser process:

1.  Direct-Child Check: In `FindViewFromFrameSinkId`, restrict targeting
to direct child views of the verified parent ancestor. This prevents a
compromised renderer from targeting nested grandchildren directly
(bypassing the ancestor chain).

2.  Geometry Validation: In `FoundFrameSinkId`, project the renderer-
provided local coordinate back to the parent target's coordinate space
using browser-computed transforms (`TransformPointToCoordSpace`).  We
verify the projected point matches the original screen click within a
2.0f DIP epsilon (to account for DSF and zoom rounding).

3.  Graceful Fallback: If validation fails (or layout transforms are out
of sync due to layout lag), the event target falls back to the parent
view. This prevents input redirection without crashing the renderer or
breaking the event state machine.

Bug: 516448843
Change-Id: Ia1e6c977750c600abbc4e100a74278a9ddedc54d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7909641
Commit-Queue: Avi Drissman <[email protected]>
Auto-Submit: Aman Verma <[email protected]>
Reviewed-by: Jonathan Ross <[email protected]>
Reviewed-by: Avi Drissman <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1643965}
---

diff --git a/components/input/render_widget_host_input_event_router.cc b/components/input/render_widget_host_input_event_router.cc
index c24bc1c..6008f2c 100644
--- a/components/input/render_widget_host_input_event_router.cc
+++ b/components/input/render_widget_host_input_event_router.cc
@@ -1894,7 +1894,11 @@
       iter == owner_map_.end() ? nullptr : iter->second.get();
 
   if (view && ancestor_to_verify && view != ancestor_to_verify &&
-      !RenderWidgetHostViewInput::IsAncestorView(view, ancestor_to_verify)) {
+      view->GetParentViewInput() != ancestor_to_verify) {
+    // We restrict targeting verification strictly to immediate direct children
+    // (parent-child relationship) rather than allowing any-depth descendants
+    // (IsAncestorView), which prevents a compromised renderer from bypassing
+    // intermediate frames to target nested grandchildren.
     return nullptr;
   }
 
diff --git a/components/input/render_widget_targeter.cc b/components/input/render_widget_targeter.cc
index f9b93b05..b9be0475 100644
--- a/components/input/render_widget_targeter.cc
+++ b/components/input/render_widget_targeter.cc
@@ -395,6 +395,26 @@
   RenderWidgetHostViewInput* resolved_view =
       delegate_->FindViewFromFrameSinkId(frame_sink_id, target.get());
 
+  if (resolved_view && resolved_view != target.get()) {
+    // Validate that the renderer-supplied transformed_location is geometrically
+    // consistent with the original target_location. A compromised parent
+    // renderer could lie about the local coordinates to redirect input to
+    // arbitrary areas of a child frame. We verify this by transforming the
+    // coordinate from the resolved sub-frame view's space (`resolved_view`)
+    // back to the queried target view's space (`target`) and comparing it with
+    // `target_location`.
+    gfx::PointF computed_point;
+    if (resolved_view->TransformPointToCoordSpaceForView(
+            transformed_location, target.get(), &computed_point)) {
+      constexpr float kEpsilon = 2.0f;
+      if (!computed_point.IsWithinDistance(target_location, kEpsilon)) {
+        resolved_view = nullptr;
+      }
+    } else {
+      resolved_view = nullptr;
+    }
+  }
+
   // Compute final target and location.
   RenderWidgetHostViewInput* final_view =
       resolved_view ? resolved_view : target.get();
diff --git a/content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc b/content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
index 5a590ad..e64d893 100644
--- a/content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
+++ b/content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
@@ -8,6 +8,7 @@
 
 #include "base/memory/raw_ptr.h"
 #include "base/run_loop.h"
+#include "base/test/run_until.h"
 #include "base/test/task_environment.h"
 #include "build/build_config.h"
 #include "components/input/features.h"
@@ -135,6 +136,14 @@
     return true;
   }
 
+  bool TransformPointToLocalCoordSpace(
+      const gfx::PointF& point,
+      const viz::FrameSinkId& original_frame_sink_id,
+      gfx::PointF* transformed_point) override {
+    *transformed_point = point + offset_;
+    return true;
+  }
+
   void SetOffset(const gfx::Vector2dF& offset) { offset_ = offset; }
 
   void ProcessGestureEvent(const blink::WebGestureEvent& event,
@@ -571,20 +580,102 @@
        FindViewFromFrameSinkIdWithAncestorVerification) {
   ChildViewState child1 = MakeChildView(view_root_.get());
   ChildViewState child2 = MakeChildView(view_root_.get());
+  ChildViewState grandchild = MakeChildView(child1.view.get());
 
-  // child2 is NOT a descendant of child1.
+  // child2 is NOT a direct child of child1.
   EXPECT_EQ(nullptr, rwhier()->FindViewFromFrameSinkId(
                          child2.view->GetFrameSinkId(), child1.view.get()));
 
-  // child2 IS a descendant of view_root_.
+  // child2 IS a direct child of view_root_.
   EXPECT_EQ(child2.view.get(),
             rwhier()->FindViewFromFrameSinkId(child2.view->GetFrameSinkId(),
                                               view_root_.get()));
 
-  // child1 IS a descendant of view_root_.
+  // child1 IS a direct child of view_root_.
   EXPECT_EQ(child1.view.get(),
             rwhier()->FindViewFromFrameSinkId(child1.view->GetFrameSinkId(),
                                               view_root_.get()));
+
+  // grandchild is a descendant of view_root_ but NOT a direct child.
+  // Under strict direct-child validation, it should return nullptr.
+  EXPECT_EQ(nullptr, rwhier()->FindViewFromFrameSinkId(
+                         grandchild.view->GetFrameSinkId(), view_root_.get()));
+
+  // grandchild IS a direct child of child1.
+  EXPECT_EQ(grandchild.view.get(),
+            rwhier()->FindViewFromFrameSinkId(grandchild.view->GetFrameSinkId(),
+                                              child1.view.get()));
+}
+
+// Verifies that during async hit testing, the browser validates that
+// coordinates returned by the renderer match the expected forward transform.
+// Confirms that honest coordinates are accepted and spoofed coordinates
+// (clickjacking attempts) are rejected and safely fall back.
+TEST_F(RenderWidgetHostInputEventRouterTest,
+       AsyncTargetingCoordinateValidation) {
+  ChildViewState child = MakeChildView(view_root_.get());
+
+  // Set child offset in the root view.
+  gfx::Vector2dF offset(10.f, 20.f);
+  view_root_->SetOffset(offset);
+
+  // Set root hit-test result to ask renderer (trigger async path).
+  view_root_->SetHittestResult(view_root_.get(), true);
+
+  // Case 1: Valid coordinate returned by renderer.
+  // Click at (100, 100) in root. Child local should be (90, 80).
+  gfx::PointF click_point(100.f, 100.f);
+  gfx::PointF valid_local_point(90.f, 80.f);
+
+  input_target_client_root_->forward_callback_ = base::BindOnce(
+      [](viz::FrameSinkId ret, gfx::PointF local_point,
+         MockInputTargetClient::FrameSinkIdAtCallback callback) {
+        if (callback) {
+          std::move(callback).Run(ret, local_point);
+        }
+      },
+      child.view->GetFrameSinkId(), valid_local_point);
+
+  // Simulate mouse event.
+  blink::WebMouseEvent mouse_event(
+      blink::WebInputEvent::Type::kMouseDown,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  mouse_event.button = blink::WebPointerProperties::Button::kLeft;
+  mouse_event.SetPositionInWidget(click_point.x(), click_point.y());
+
+  // Route event. The targeter should query the client.
+  rwhier()->RouteMouseEvent(view_root_.get(), &mouse_event, ui::LatencyInfo());
+
+  // Wait for callback.
+  ASSERT_TRUE(base::test::RunUntil(
+      [&]() { return last_mouse_down_target() == child.view.get(); }));
+
+  // The event should be successfully dispatched to the child view.
+  EXPECT_EQ(child.view.get(), last_mouse_down_target());
+
+  // Case 2: Spoofed coordinate returned by renderer.
+  // Click at (100, 100). Child returns (50, 50) which is far outside epsilon
+  // (2.0).
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc b/content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
index 5a590ad..e64d893 100644
--- a/content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
+++ b/content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
@@ -8,6 +8,7 @@
 
 #include "base/memory/raw_ptr.h"
 #include "base/run_loop.h"
+#include "base/test/run_until.h"
 #include "base/test/task_environment.h"
 #include "build/build_config.h"
 #include "components/input/features.h"
@@ -135,6 +136,14 @@
     return true;
   }
 
+  bool TransformPointToLocalCoordSpace(
+      const gfx::PointF& point,
+      const viz::FrameSinkId& original_frame_sink_id,
+      gfx::PointF* transformed_point) override {
+    *transformed_point = point + offset_;
+    return true;
+  }
+
   void SetOffset(const gfx::Vector2dF& offset) { offset_ = offset; }
 
   void ProcessGestureEvent(const blink::WebGestureEvent& event,
@@ -571,20 +580,102 @@
        FindViewFromFrameSinkIdWithAncestorVerification) {
   ChildViewState child1 = MakeChildView(view_root_.get());
   ChildViewState child2 = MakeChildView(view_root_.get());
+  ChildViewState grandchild = MakeChildView(child1.view.get());
 
-  // child2 is NOT a descendant of child1.
+  // child2 is NOT a direct child of child1.
   EXPECT_EQ(nullptr, rwhier()->FindViewFromFrameSinkId(
                          child2.view->GetFrameSinkId(), child1.view.get()));
 
-  // child2 IS a descendant of view_root_.
+  // child2 IS a direct child of view_root_.
   EXPECT_EQ(child2.view.get(),
             rwhier()->FindViewFromFrameSinkId(child2.view->GetFrameSinkId(),
                                               view_root_.get()));
 
-  // child1 IS a descendant of view_root_.
+  // child1 IS a direct child of view_root_.
   EXPECT_EQ(child1.view.get(),
             rwhier()->FindViewFromFrameSinkId(child1.view->GetFrameSinkId(),
                                               view_root_.get()));
+
+  // grandchild is a descendant of view_root_ but NOT a direct child.
+  // Under strict direct-child validation, it should return nullptr.
+  EXPECT_EQ(nullptr, rwhier()->FindViewFromFrameSinkId(
+                         grandchild.view->GetFrameSinkId(), view_root_.get()));
+
+  // grandchild IS a direct child of child1.
+  EXPECT_EQ(grandchild.view.get(),
+            rwhier()->FindViewFromFrameSinkId(grandchild.view->GetFrameSinkId(),
+                                              child1.view.get()));
+}
+
+// Verifies that during async hit testing, the browser validates that
+// coordinates returned by the renderer match the expected forward transform.
+// Confirms that honest coordinates are accepted and spoofed coordinates
+// (clickjacking attempts) are rejected and safely fall back.
+TEST_F(RenderWidgetHostInputEventRouterTest,
+       AsyncTargetingCoordinateValidation) {
+  ChildViewState child = MakeChildView(view_root_.get());
+
+  // Set child offset in the root view.
+  gfx::Vector2dF offset(10.f, 20.f);
+  view_root_->SetOffset(offset);
+
+  // Set root hit-test result to ask renderer (trigger async path).
+  view_root_->SetHittestResult(view_root_.get(), true);
+
+  // Case 1: Valid coordinate returned by renderer.
+  // Click at (100, 100) in root. Child local should be (90, 80).
+  gfx::PointF click_point(100.f, 100.f);
+  gfx::PointF valid_local_point(90.f, 80.f);
+
+  input_target_client_root_->forward_callback_ = base::BindOnce(
+      [](viz::FrameSinkId ret, gfx::PointF local_point,
+         MockInputTargetClient::FrameSinkIdAtCallback callback) {
+        if (callback) {
+          std::move(callback).Run(ret, local_point);
+        }
+      },
+      child.view->GetFrameSinkId(), valid_local_point);
+
+  // Simulate mouse event.
+  blink::WebMouseEvent mouse_event(
+      blink::WebInputEvent::Type::kMouseDown,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  mouse_event.button = blink::WebPointerProperties::Button::kLeft;
+  mouse_event.SetPositionInWidget(click_point.x(), click_point.y());
+
+  // Route event. The targeter should query the client.
+  rwhier()->RouteMouseEvent(view_root_.get(), &mouse_event, ui::LatencyInfo());
+
+  // Wait for callback.
+  ASSERT_TRUE(base::test::RunUntil(
+      [&]() { return last_mouse_down_target() == child.view.get(); }));
+
+  // The event should be successfully dispatched to the child view.
+  EXPECT_EQ(child.view.get(), last_mouse_down_target());
+
+  // Case 2: Spoofed coordinate returned by renderer.
+  // Click at (100, 100). Child returns (50, 50) which is far outside epsilon
+  // (2.0).
+  gfx::PointF spoofed_local_point(50.f, 50.f);
+
+  input_target_client_root_->forward_callback_ = base::BindOnce(
+      [](viz::FrameSinkId ret, gfx::PointF local_point,
+         MockInputTargetClient::FrameSinkIdAtCallback callback) {
+        if (callback) {
+          std::move(callback).Run(ret, local_point);
+        }
+      },
+      child.view->GetFrameSinkId(), spoofed_local_point);
+
+  // Route event again.
+  rwhier()->RouteMouseEvent(view_root_.get(), &mouse_event, ui::LatencyInfo());
+  ASSERT_TRUE(base::test::RunUntil(
+      [&]() { return last_mouse_down_target() == view_root_.get(); }));
+
+  // Validation should fail, and we should fall back to targeting the parent
+  // (view_root_).
+  EXPECT_EQ(view_root_.get(), last_mouse_down_target());
 }
 
 TEST_F(RenderWidgetHostInputEventRouterTest, DoNotCoalesceTouchEvents) {
diff --git a/content/browser/site_per_process_hit_test_browsertest.cc b/content/browser/site_per_process_hit_test_browsertest.cc
index eb0f36f..cd44d012 100644
--- a/content/browser/site_per_process_hit_test_browsertest.cc
+++ b/content/browser/site_per_process_hit_test_browsertest.cc
@@ -4547,6 +4547,9 @@
   auto* router = web_contents()->GetInputEventRouter();
 
   // Scroll the main frame.
+  HitTestRegionObserver hit_test_data_change_observer(
+      root_view->GetRootFrameSinkId());
+  hit_test_data_change_observer.WaitForHitTestData();
   gfx::Rect initial_child_view_bounds = child_view->GetViewBounds();
   EXPECT_TRUE(ExecJs(root, "window.scrollTo(0, 10);"));
   // Wait until the OOPIF positions have been updated in the browser process.
@@ -4554,6 +4557,7 @@
     return initial_child_view_bounds.y() ==
            child_view->GetViewBounds().y() + 10;
   }));
+  hit_test_data_change_observer.WaitForHitTestDataChange();
 
   // A cursor should not be shown when the main frame is scrolled
   // and the iframe is outside the root view's visible viewport.
Loading diff…

Original Bug Report

reported by [email protected]

Input redirection via RenderWidgetTargeter and unvalidated coordinates

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: RenderWidgetTargeter::FoundFrameSinkId accepts any-depth descendant FrameSinkIds during hit-testing and trusts renderer-supplied coordinates without validation. A compromised renderer could potentially exploit this to redirect genuine user click events to arbitrary coordinates inside a nested cross-origin descendant frame. This bypasses standard clickjacking protections because the target frame’s actual screen bounds remain unchanged.

Affected files:

  • components/input/render_widget_targeter.cc
  • components/input/render_widget_host_input_event_router.cc
  • components/input/render_widget_host_view_input.cc

Estimated timestamp from git blame: 2017-12-23

Description

During input event hit-testing, RenderWidgetTargeter::FoundFrameSinkId receives a target FrameSinkId and a transformed_location from the queried renderer (which may be a compromised renderer process).

There are two primary concerns with how the browser validates and processes this response:

1. Any-Depth Descendant Acceptance

The delegate validation inside RenderWidgetHostInputEventRouter::FindViewFromFrameSinkId uses IsAncestorView to verify that the returned view is a descendant of the queried target:

if (view && ancestor_to_verify && view != ancestor_to_verify &&
    !RenderWidgetHostViewInput::IsAncestorView(view, ancestor_to_verify)) {
  return nullptr;
}

Because IsAncestorView (defined in components/input/render_widget_host_view_input.cc) recursively walks up the parent view chain to any depth, it accepts nested grandchild views. This allows a compromised renderer to bypass intermediate honest frames and target a nested grandchild frame directly.

2. Unvalidated and Unclamped Coordinates

The browser process trusts the renderer-supplied transformed_location coordinate mapping directly without clamping or verifying it against the target’s actual geometry:

gfx::PointF final_location =
    resolved_view ? transformed_location : target_location;

During event dispatch in RenderWidgetHostInputEventRouter::DispatchMouseEvent, the original event coordinates are overwritten with the untrusted target_location:

blink::WebMouseEvent event = mouse_event;
event.SetPositionInWidget(target_location->x(), target_location->y());
...
target->ProcessMouseEvent(event, latency);

No verification is performed in the browser process to check if the original click coordinate actually intersects the target frame’s screen geometry, or if the transformed coordinates are within the legitimate clip bounds of the target view.

Potential Attack Scenario

If an attacker has compromised a renderer hosting a page that embeds a cross-origin victim frame, they could potentially perform precise clickjacking:

  1. A user clicks anywhere inside the attacker’s frame.
  2. The browser queries the attacker’s renderer for the hit target via Mojo FrameSinkIdAt.
  3. The compromised renderer responds with the FrameSinkId of a deep descendant frame and arbitrary, attacker-chosen coordinates representing a sensitive element within that target frame (even if those coordinates are scrolled/clipped out of view or hidden).
  4. The browser dispatches the genuine user gesture click to the descendant frame at the attacker’s chosen coordinates.

Because the target frame’s actual screen bounds did not move, clickjacking mitigations such as the kTargetFrameMovedRecently flag (checked via ScreenRectIsUnstableFor()) are not triggered.

Note: These are potential steps derived from static code analysis. Our tooling does not currently have the capability to run code or dynamically verify this vulnerability with a proof-of-concept.

Suggested Fix

  1. Enforce that the returned FrameSinkId must correspond to either the queried view itself or a direct, immediately embedded child view rather than permitting any-depth descendants.
  2. Clamp or validate the renderer-supplied coordinates to ensure they lie within the legitimate geometry or clip bounds of the resolved target view before updating and dispatching the input event.

Evaluated with Chrome root at commit: a2bea94528f4bd6cc57739c43fa3bb890b8367d3


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