Medium chrome Input Validation 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Input
DescriptionImproper input validation in Input
ComponentInput
Bug ClassInput Validation
Tracker567673405
Fix commit6403b3625a99 (chromium/src) +304/-37
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
components/input/render_widget_host_input_event_router.cc
modified
if
content/browser/renderer_host/render_widget_host_view_base.cc
modified
TEST_F
content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
modified
BindLambdaForTesting
content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/render_widget_host_input_event_router_unittest.cc
  • content/browser/renderer_host/render_widget_host_view_base.h
  • components/input/render_widget_host_input_event_router.cc
  • content/browser/renderer_host/render_widget_host_view_aura_unittest.cc
  • content/browser/renderer_host/render_widget_host_view_base.cc
From 6403b3625a9970e788768ba9b6b45648a56eb88c Mon Sep 17 00:00:00 2001
From: Jonathan Ross <[email protected]>
Date: Fri, 02 Oct 2026 12:37:20 -0700
Subject: [PATCH] Prevent premature view deletion during DestroyOrDefer() teardown

RenderWidgetHostViewBase::DestroyOrDefer() sets destroy_pending_
and then notifies observers. The RenderWidgetHostInputEventRouter
observer flushes unacked touch events, which can synchronously
generate a GestureScrollBegin. That dispatches a pending synthetic 
wheel-end event to the stale |wheel_target_|, i.e. the view being
destroyed. The ScopedInputDispatchPin taken for that dispatch is
released while pin_count_ was otherwise 0, so
UnpinForInputDispatch() called DestroyImpl() and deleted the view
while DestroyOrDefer() was still on the stack. The remainder of
teardown then used the freed view, and DestroyImpl() ran a second
time.

Fix this in two ways:
 - Track when DestroyOrDefer() is executing, and do not call
   DestroyImpl() from UnpinForInputDispatch() during that time.
   DestroyOrDefer() decides once teardown has completed. This
   closes the general class of any observer pinning the view during
   teardown.
 - Split RenderWidgetHostInputEventRouter's
   OnRenderWidgetHostViewInputDestroyed() into two phases. First,
   drop every reference to the dying view without calling out of
   the router: the wheel, mouse capture, touchpad and touchscreen
   gesture targets, the touchscreen gesture target map, scroll
   bubbling state, and the targeter's middle-click autoscroll
   target. Then, as the final step, call
   UpdateQueueAfterTargetDestroyed() to flush the touch ack queue.
   Events generated by the flush, including a GestureScrollEnd sent
   when cancelling scroll bubbling, can therefore no longer be
   routed to the dying view.
 - Release pointer lock before unregistering from the router, since
   while locked the router targets mouse and wheel events generated
   by the flush at the pointer lock view.

This regressed in 966cb0357f75f ("Harden RenderWidgetHostViewBase
Teardown").

Bug: 567673405
Change-Id: I1d312afca1a625dd23e64b863d6ad9d9b1f0e650
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8484639
Reviewed-by: Aman Verma <[email protected]>
Commit-Queue: Jonathan Ross <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1710331}
---
diff --git a/components/input/render_widget_host_input_event_router.cc b/components/input/render_widget_host_input_event_router.cc
index 51e4bd1b..4f4887a 100644
--- a/components/input/render_widget_host_input_event_router.cc
+++ b/components/input/render_widget_host_input_event_router.cc
@@ -396,22 +396,38 @@
     touch_emulator->OnViewDestroyed(view);
   }
 
-  // Must be cleared before UpdateQueueAfterTargetDestroyed(), which can
-  // synchronously flush touch acks and route a GestureFlingCancel. Otherwise
-  // that cancel would be dispatched to, and pin, |view| while it is being
-  // destroyed.
+  // Teardown is split into two phases. |view| is mid-destruction, so:
+  //
+  // Phase 1 drops every reference to |view| that event routing could follow.
+  // Nothing in this phase may call out of the router.
+  //
+  // Phase 2 flushes queued touch acks, which can synchronously generate
+  // gestures and synthetic events (e.g. a GestureFlingCancel, a
+  // GestureScrollEnd for scroll bubbling, or a wheel-end dispatched on
+  // GestureScrollBegin). Because of phase 1, none of these can be routed to,
+  // and pin, |view|.
+
+  // Phase 1: Detach.
   if (view == last_fling_start_target_) {
     last_fling_start_target_ = nullptr;
   }
 
+  if (view == wheel_target_) {
+    wheel_target_ = nullptr;
+  }
+
+  if (view == mouse_capture_target_) {
+    mouse_capture_target_ = nullptr;
+  }
+
+  if (view == touchpad_gesture_target_) {
+    touchpad_gesture_target_ = nullptr;
+  }
+
   if (view == touch_target_) {
     touch_target_ = nullptr;
     active_touches_ = 0;
   }
-  touch_event_ack_queue_->UpdateQueueAfterTargetDestroyed(view);
-
-  if (view == wheel_target_)
-    wheel_target_ = nullptr;
 
   // If the target that's being destroyed is in the gesture target map, we
   // replace it with nullptr so that we maintain the 1:1 correspondence between
@@ -421,16 +437,12 @@
       it.second = nullptr;
   }
 
-  if (view == mouse_capture_target_)
-    mouse_capture_target_ = nullptr;
-
   if (view == touchscreen_gesture_target_.get()) {
     ClearTouchscreenGestureTarget();
   }
 
-  if (view == touchpad_gesture_target_)
-    touchpad_gesture_target_ = nullptr;
-
+  // With |bubbling_view_is_being_destroyed| set, this does not dispatch a
+  // GestureScrollEnd to |view|.
   if (view == bubbling_gesture_scroll_target_) {
     CancelScrollBubbling(/*bubbling_view_is_being_destroyed=*/true);
   } else if (view == bubbling_gesture_scroll_origin_) {
@@ -462,7 +474,12 @@
   if (view == last_emulated_event_root_view_)
     last_emulated_event_root_view_ = nullptr;
 
+  // Clears the targeter's cached middle-click autoscroll target, which would
+  // otherwise receive every web input event routed during phase 2.
   event_targeter_->ViewWillBeDestroyed(view);
+
+  // Phase 2: Flush. This must remain last.
+  touch_event_ack_queue_->UpdateQueueAfterTargetDestroyed(view);
 }
 
 void RenderWidgetHostInputEventRouter::ClearAllObserverRegistrations() {
diff --git a/content/browser/renderer_host/render_widget_host_view_base.cc b/content/browser/renderer_host/render_widget_host_view_base.cc
index 1fc2d29..79329c85 100644
--- a/content/browser/renderer_host/render_widget_host_view_base.cc
+++ b/content/browser/renderer_host/render_widget_host_view_base.cc
@@ -6,6 +6,7 @@
 
 #include <optional>
 
+#include "base/auto_reset.h"
 #include "base/check.h"
 #include "base/feature_list.h"
 #include "base/functional/bind.h"
@@ -862,30 +863,41 @@
     return;
   }
   destroy_pending_ = true;
-  if (is_frame_sink_id_owner_ && host() && host()->GetInputEventRouter()) {
-    host()->GetInputEventRouter()->RemoveFrameSinkIdOwner(GetFrameSinkId());
-  }
-  if (IsPointerLocked()) {
-    UnlockPointer();
-  }
+  {
+    // Observers notified below can synchronously dispatch input to `this`,
+    // taking and releasing an `input::ScopedInputDispatchPin`. Prevent that
+    // release from calling `DestroyImpl()` while teardown is still on the
+    // stack; the decision is made below once teardown has completed.
+    base::AutoReset<bool> in_destroy_or_defer(&in_destroy_or_defer_, true);
+    // Release pointer lock first. While locked, the router targets mouse and
+    // wheel events at the pointer lock view regardless of hit testing, so
+    // events generated by RemoveFrameSinkIdOwner()'s touch ack flush would
+    // otherwise still be routed to `this`.
+    if (IsPointerLocked()) {
+      UnlockPointer();
+    }
+    if (is_frame_sink_id_owner_ && host() && host()->GetInputEventRouter()) {
+      host()->GetInputEventRouter()->RemoveFrameSinkIdOwner(GetFrameSinkId());
+    }
 #if BUILDFLAG(IS_WIN)
-  if (StylusHandwritingControllerWin::IsHandwritingAPIAvailable()) {
-    StylusHandwritingControllerWin::GetInstance()->OnHandwritingViewDestroyed(
-        this);
-  }
+    if (StylusHandwritingControllerWin::IsHandwritingAPIAvailable()) {
+      StylusHandwritingControllerWin::GetInstance()->OnHandwritingViewDestroyed(
+          this);
+    }
 #endif  // BUILDFLAG(IS_WIN)
-  OnDestroyOrDefer();
-  NotifyObserversAboutShutdown();
-  CleanUpHostObservers();
-  if (unbounded_surface_window_) {
-    DestroyUnboundedSurface(unbounded_surface_window_->GetWeakPtr());
+    OnDestroyOrDefer();
+    NotifyObserversAboutShutdown();
+    CleanUpHostObservers();
+    if (unbounded_surface_window_) {
+      DestroyUnboundedSurface(unbounded_surface_window_->GetWeakPtr());
+    }
+    if (text_input_manager_) {
+      text_input_manager_->Unregister(this);
+      text_input_manager_ = nullptr;
+    }
+    host_ = nullptr;
+    weak_factory_.InvalidateWeakPtrs();
   }
-  if (text_input_manager_) {
-    text_input_manager_->Unregister(this);
-    text_input_manager_ = nullptr;
-  }
-  host_ = nullptr;
-  weak_factory_.InvalidateWeakPtrs();
   if (pin_count_ > 0) {
     return;
   }
@@ -899,7 +911,9 @@
 void RenderWidgetHostViewBase::UnpinForInputDispatch() {
   CHECK(pin_count_ > 0);
   pin_count_--;
-  if (pin_count_ == 0 && destroy_pending_) {
+  // While `DestroyOrDefer()` is on the stack it owns the decision to call
+  // `DestroyImpl()`.
+  if (pin_count_ == 0 && destroy_pending_ && !in_destroy_or_defer_) {
     DestroyImpl();
   }
 }
diff --git a/content/browser/renderer_host/render_widget_host_view_base.h b/content/browser/renderer_host/render_widget_host_view_base.h
index 9f2c3c71..71a4d33 100644
--- a/content/browser/renderer_host/render_widget_host_view_base.h
+++ b/content/browser/renderer_host/render_widget_host_view_base.h
@@ -429,7 +429,10 @@
   // processing.
   //
   // Once the last `input::ScopedInputDispatchPin` has been removed
-  // `DestroyImpl` is called to actually `delete this`.
+  // `DestroyImpl` is called to actually `delete this`. Pins that are both
+  // acquired and released while `DestroyOrDefer` is executing (e.g. by an
+  // observer synchronously dispatching input) never trigger `DestroyImpl`;
+  // `DestroyOrDefer` itself decides once teardown is complete.
   void DestroyOrDefer();
   virtual void DestroyImpl() = 0;
   virtual void OnDestroyOrDefer() {}
@@ -802,6 +805,12 @@
   // early-exit visual updates that are no longer required. So that we can
   // gracefully clean up input dispatch.
   bool destroy_pending_ = false;
+  // `true` while `DestroyOrDefer` is on the stack. Observers notified during
+  // teardown can synchronously dispatch input to, and pin, `this`. Releasing
+  // such a pin must not call `DestroyImpl` while `DestroyOrDefer` is still
+  // executing, otherwise `this` would be deleted out from under it, and then
+  // deleted a second time when `DestroyOrDefer` completes.
+  bool in_destroy_or_defer_ = false;
 
   base::WeakPtrFactory<RenderWidgetHostViewBase> weak_factory_{this};
 };
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 d21e79a7..3d2076c 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
@@ -1940,6 +1940,178 @@
                               ui::LatencyInfo());
 }
 
+// Regression test for crbug.com/567673405. When a view that is the current
+// |wheel_target_| is destroyed with an unacked touch event in the queue,
+// flushing the queue can generate a GestureScrollBegin, which synchronously
+// dispatches a pending synthetic wheel-end event. That event must not be
+// routed to, and pin, the view being destroyed.
+TEST_F(RenderWidgetHostInputEventRouterTest, WheelEndAfterTargetDestroyed) {
+  ChildViewState child = MakeChildView(view_root_.get());
+  view_root_->SetHittestResult(child.view.get(), false);
+
+  blink::WebMouseWheelEvent wheel_begin(
+      blink::WebInputEvent::Type::kMouseWheel,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  wheel_begin.SetPositionInWidget(gfx::PointF(20, 21));
+  wheel_begin.delta_y = 10;
+  wheel_begin.phase = blink::WebMouseWheelEvent::kPhaseBegan;
+  rwhier()->RouteMouseWheelEvent(view_root_.get(), &wheel_begin,
+                                 ui::LatencyInfo());
+  EXPECT_EQ(child.view.get(), wheel_target());
+
+  blink::WebTouchEvent touch_event(
+      blink::WebInputEvent::Type::kTouchStart,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  touch_event.touches_length = 1;
+  touch_event.touches[0].state = blink::WebTouchPoint::State::kStatePressed;
+  touch_event.unique_touch_event_id = 1;
+  rwhier()->RouteTouchEvent(view_root_.get(), &touch_event, ui::LatencyInfo());
+  EXPECT_EQ(child.view.get(), touch_target());
+
+  bool ack_callback_ran = false;
+  view_root_->set_on_process_acked_touch_event_callback(
+      base::BindLambdaForTesting([&]() {
+        ack_callback_ran = true;
+        EXPECT_EQ(nullptr, wheel_target());
+        // Simulate MouseWheelPhaseHandler::DispatchPendingWheelEndEvent().
+        blink::WebMouseWheelEvent wheel_end(
+            blink::WebInputEvent::Type::kMouseWheel,
+            blink::WebInputEvent::kNoModifiers,
+            blink::WebInputEvent::GetStaticTimeStampForTests());
+        wheel_end.SetPositionInWidget(gfx::PointF(20, 21));
+        wheel_end.phase = blink::WebMouseWheelEvent::kPhaseEnded;
+        rwhier()->RouteMouseWheelEvent(view_root_.get(), &wheel_end,
+                                       ui::LatencyInfo());
+      }));
+
+  rwhier()->OnRenderWidgetHostViewInputDestroyed(child.view.get());
+  EXPECT_TRUE(ack_callback_ran);
+  EXPECT_EQ(nullptr, wheel_target());
+}
+
+// Regression test for crbug.com/567673405. When a view that is the current
+// scroll bubbling target is destroyed with an unacked touch event in the
+// queue, events generated while flushing the queue must not cancel scroll
+// bubbling by sending a GestureScrollEnd to the view being destroyed.
+TEST_F(RenderWidgetHostInputEventRouterTest,
+       WheelEndAfterBubblingTargetDestroyed) {
+  // Bubbling a touchpad scroll to a view with an active touch sequence relies
+  // on this feature.
+  if (!base::FeatureList::IsEnabled(
+          input::features::kIgnoreBubblingCollisionIfSourceDevicesMismatch)) {
+    return;
+  }
+
+  ChildViewState outer = MakeChildView(view_root_.get());
+  ChildViewState inner = MakeChildView(outer.view.get());
+
+  view_root_->SetHittestResult(outer.view.get(), false);
+  blink::WebTouchEvent touch_event(
+      blink::WebInputEvent::Type::kTouchStart,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  touch_event.touches_length = 1;
+  touch_event.touches[0].state = blink::WebTouchPoint::State::kStatePressed;
+  touch_event.unique_touch_event_id = 1;
+  rwhier()->RouteTouchEvent(view_root_.get(), &touch_event, ui::LatencyInfo());
+  ASSERT_EQ(outer.view.get(), touch_target());
+
+  // Start bubbling after the touch sequence starts, since a touch start on
+  // the bubbling target would otherwise cancel bubbling.
+  ASSERT_TRUE(rwhier()->BubbleScrollEvent(
+      outer.view.get(), inner.view.get(),
+      blink::SyntheticWebGestureEventBuilder::BuildScrollBegin(
+          0.f, 10.f, blink::WebGestureDevice::kTouchpad)));
+  ASSERT_EQ(outer.view.get(), bubbling_gesture_scroll_target());
+
+  view_root_->SetHittestResult(view_root_.get(), false);
+  outer.view->Reset();
+
+  bool ack_callback_ran = false;
+  view_root_->set_on_process_acked_touch_event_callback(
+      base::BindLambdaForTesting([&]() {
+        ack_callback_ran = true;
+        EXPECT_EQ(nullptr, bubbling_gesture_scroll_target());
+        EXPECT_EQ(nullptr, bubbling_gesture_scroll_origin());
+        // With no |wheel_target_|, a wheel-end cancels any ongoing scroll
+        // bubbling, which sends a GestureScrollEnd to the bubbling target.
+        blink::WebMouseWheelEvent wheel_end(
+            blink::WebInputEvent::Type::kMouseWheel,
+            blink::WebInputEvent::kNoModifiers,
+            blink::WebInputEvent::GetStaticTimeStampForTests());
+        wheel_end.SetPositionInWidget(gfx::PointF(20, 21));
+        wheel_end.phase = blink::WebMouseWheelEvent::kPhaseEnded;
+        rwhier()->RouteMouseWheelEvent(view_root_.get(), &wheel_end,
+                                       ui::LatencyInfo());
+      }));
+
+  rwhier()->OnRenderWidgetHostViewInputDestroyed(outer.view.get());
+  EXPECT_TRUE(ack_callback_ran);
+  EXPECT_EQ(nullptr, bubbling_gesture_scroll_target());
+  EXPECT_EQ(blink::WebInputEvent::Type::kUndefined,
+            outer.view->last_gesture_seen());
+}
+
+// Regression test for crbug.com/567673405. While middle click autoscroll is in
+// progress, the targeter routes every event to the view that received the
+// middle click. When that view is destroyed with an unacked touch event in the
+// queue, events generated while flushing the queue must not be targeted at the
+// view being destroyed.
+TEST_F(RenderWidgetHostInputEventRouterTest,
+       WheelEndDuringAutoscrollAfterTargetDestroyed) {
+  ChildViewState child = MakeChildView(view_root_.get());
+  view_root_->SetHittestResult(child.view.get(), false);
+
+  blink::WebTouchEvent touch_event(
+      blink::WebInputEvent::Type::kTouchStart,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  touch_event.touches_length = 1;
+  touch_event.touches[0].state = blink::WebTouchPoint::State::kStatePressed;
+  touch_event.unique_touch_event_id = 1;
+  rwhier()->RouteTouchEvent(view_root_.get(), &touch_event, ui::LatencyInfo());
+  ASSERT_EQ(child.view.get(), touch_target());
+
+  blink::WebMouseEvent middle_down_event(
+      blink::WebInputEvent::Type::kMouseDown,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  middle_down_event.button = blink::WebPointerProperties::Button::kMiddle;
+  middle_down_event.SetPositionInWidget(50, 50);
+  rwhier()->RouteMouseEvent(view_root_.get(), &middle_down_event,
+                            ui::LatencyInfo());
+  rwhier()->SetAutoScrollInProgress(child.view.get(), true);
+  input::RenderWidgetTargeter* targeter =
+      rwhier()->GetRenderWidgetTargeterForTests();
+  ASSERT_TRUE(targeter->is_auto_scroll_in_progress());
... (file diff truncated)
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 d21e79a7..3d2076c 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
@@ -1940,6 +1940,178 @@
                               ui::LatencyInfo());
 }
 
+// Regression test for crbug.com/567673405. When a view that is the current
+// |wheel_target_| is destroyed with an unacked touch event in the queue,
+// flushing the queue can generate a GestureScrollBegin, which synchronously
+// dispatches a pending synthetic wheel-end event. That event must not be
+// routed to, and pin, the view being destroyed.
+TEST_F(RenderWidgetHostInputEventRouterTest, WheelEndAfterTargetDestroyed) {
+  ChildViewState child = MakeChildView(view_root_.get());
+  view_root_->SetHittestResult(child.view.get(), false);
+
+  blink::WebMouseWheelEvent wheel_begin(
+      blink::WebInputEvent::Type::kMouseWheel,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  wheel_begin.SetPositionInWidget(gfx::PointF(20, 21));
+  wheel_begin.delta_y = 10;
+  wheel_begin.phase = blink::WebMouseWheelEvent::kPhaseBegan;
+  rwhier()->RouteMouseWheelEvent(view_root_.get(), &wheel_begin,
+                                 ui::LatencyInfo());
+  EXPECT_EQ(child.view.get(), wheel_target());
+
+  blink::WebTouchEvent touch_event(
+      blink::WebInputEvent::Type::kTouchStart,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  touch_event.touches_length = 1;
+  touch_event.touches[0].state = blink::WebTouchPoint::State::kStatePressed;
+  touch_event.unique_touch_event_id = 1;
+  rwhier()->RouteTouchEvent(view_root_.get(), &touch_event, ui::LatencyInfo());
+  EXPECT_EQ(child.view.get(), touch_target());
+
+  bool ack_callback_ran = false;
+  view_root_->set_on_process_acked_touch_event_callback(
+      base::BindLambdaForTesting([&]() {
+        ack_callback_ran = true;
+        EXPECT_EQ(nullptr, wheel_target());
+        // Simulate MouseWheelPhaseHandler::DispatchPendingWheelEndEvent().
+        blink::WebMouseWheelEvent wheel_end(
+            blink::WebInputEvent::Type::kMouseWheel,
+            blink::WebInputEvent::kNoModifiers,
+            blink::WebInputEvent::GetStaticTimeStampForTests());
+        wheel_end.SetPositionInWidget(gfx::PointF(20, 21));
+        wheel_end.phase = blink::WebMouseWheelEvent::kPhaseEnded;
+        rwhier()->RouteMouseWheelEvent(view_root_.get(), &wheel_end,
+                                       ui::LatencyInfo());
+      }));
+
+  rwhier()->OnRenderWidgetHostViewInputDestroyed(child.view.get());
+  EXPECT_TRUE(ack_callback_ran);
+  EXPECT_EQ(nullptr, wheel_target());
+}
+
+// Regression test for crbug.com/567673405. When a view that is the current
+// scroll bubbling target is destroyed with an unacked touch event in the
+// queue, events generated while flushing the queue must not cancel scroll
+// bubbling by sending a GestureScrollEnd to the view being destroyed.
+TEST_F(RenderWidgetHostInputEventRouterTest,
+       WheelEndAfterBubblingTargetDestroyed) {
+  // Bubbling a touchpad scroll to a view with an active touch sequence relies
+  // on this feature.
+  if (!base::FeatureList::IsEnabled(
+          input::features::kIgnoreBubblingCollisionIfSourceDevicesMismatch)) {
+    return;
+  }
+
+  ChildViewState outer = MakeChildView(view_root_.get());
+  ChildViewState inner = MakeChildView(outer.view.get());
+
+  view_root_->SetHittestResult(outer.view.get(), false);
+  blink::WebTouchEvent touch_event(
+      blink::WebInputEvent::Type::kTouchStart,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  touch_event.touches_length = 1;
+  touch_event.touches[0].state = blink::WebTouchPoint::State::kStatePressed;
+  touch_event.unique_touch_event_id = 1;
+  rwhier()->RouteTouchEvent(view_root_.get(), &touch_event, ui::LatencyInfo());
+  ASSERT_EQ(outer.view.get(), touch_target());
+
+  // Start bubbling after the touch sequence starts, since a touch start on
+  // the bubbling target would otherwise cancel bubbling.
+  ASSERT_TRUE(rwhier()->BubbleScrollEvent(
+      outer.view.get(), inner.view.get(),
+      blink::SyntheticWebGestureEventBuilder::BuildScrollBegin(
+          0.f, 10.f, blink::WebGestureDevice::kTouchpad)));
+  ASSERT_EQ(outer.view.get(), bubbling_gesture_scroll_target());
+
+  view_root_->SetHittestResult(view_root_.get(), false);
+  outer.view->Reset();
+
+  bool ack_callback_ran = false;
+  view_root_->set_on_process_acked_touch_event_callback(
+      base::BindLambdaForTesting([&]() {
+        ack_callback_ran = true;
+        EXPECT_EQ(nullptr, bubbling_gesture_scroll_target());
+        EXPECT_EQ(nullptr, bubbling_gesture_scroll_origin());
+        // With no |wheel_target_|, a wheel-end cancels any ongoing scroll
+        // bubbling, which sends a GestureScrollEnd to the bubbling target.
+        blink::WebMouseWheelEvent wheel_end(
+            blink::WebInputEvent::Type::kMouseWheel,
+            blink::WebInputEvent::kNoModifiers,
+            blink::WebInputEvent::GetStaticTimeStampForTests());
+        wheel_end.SetPositionInWidget(gfx::PointF(20, 21));
+        wheel_end.phase = blink::WebMouseWheelEvent::kPhaseEnded;
+        rwhier()->RouteMouseWheelEvent(view_root_.get(), &wheel_end,
+                                       ui::LatencyInfo());
+      }));
+
+  rwhier()->OnRenderWidgetHostViewInputDestroyed(outer.view.get());
+  EXPECT_TRUE(ack_callback_ran);
+  EXPECT_EQ(nullptr, bubbling_gesture_scroll_target());
+  EXPECT_EQ(blink::WebInputEvent::Type::kUndefined,
+            outer.view->last_gesture_seen());
+}
+
+// Regression test for crbug.com/567673405. While middle click autoscroll is in
+// progress, the targeter routes every event to the view that received the
+// middle click. When that view is destroyed with an unacked touch event in the
+// queue, events generated while flushing the queue must not be targeted at the
+// view being destroyed.
+TEST_F(RenderWidgetHostInputEventRouterTest,
+       WheelEndDuringAutoscrollAfterTargetDestroyed) {
+  ChildViewState child = MakeChildView(view_root_.get());
+  view_root_->SetHittestResult(child.view.get(), false);
+
+  blink::WebTouchEvent touch_event(
+      blink::WebInputEvent::Type::kTouchStart,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  touch_event.touches_length = 1;
+  touch_event.touches[0].state = blink::WebTouchPoint::State::kStatePressed;
+  touch_event.unique_touch_event_id = 1;
+  rwhier()->RouteTouchEvent(view_root_.get(), &touch_event, ui::LatencyInfo());
+  ASSERT_EQ(child.view.get(), touch_target());
+
+  blink::WebMouseEvent middle_down_event(
+      blink::WebInputEvent::Type::kMouseDown,
+      blink::WebInputEvent::kNoModifiers,
+      blink::WebInputEvent::GetStaticTimeStampForTests());
+  middle_down_event.button = blink::WebPointerProperties::Button::kMiddle;
+  middle_down_event.SetPositionInWidget(50, 50);
+  rwhier()->RouteMouseEvent(view_root_.get(), &middle_down_event,
+                            ui::LatencyInfo());
+  rwhier()->SetAutoScrollInProgress(child.view.get(), true);
+  input::RenderWidgetTargeter* targeter =
+      rwhier()->GetRenderWidgetTargeterForTests();
+  ASSERT_TRUE(targeter->is_auto_scroll_in_progress());
+
+  view_root_->SetHittestResult(view_root_.get(), false);
+
+  bool ack_callback_ran = false;
+  view_root_->set_on_process_acked_touch_event_callback(
+      base::BindLambdaForTesting([&]() {
+        ack_callback_ran = true;
+        // The targeter must have already dropped the view being destroyed as
+        // the autoscroll target, so the wheel event below is hit tested
+        // rather than sent to, and pinning, that view.
+        EXPECT_FALSE(targeter->is_auto_scroll_in_progress());
+        blink::WebMouseWheelEvent wheel_end(
+            blink::WebInputEvent::Type::kMouseWheel,
+            blink::WebInputEvent::kNoModifiers,
+            blink::WebInputEvent::GetStaticTimeStampForTests());
+        wheel_end.SetPositionInWidget(gfx::PointF(20, 21));
+        wheel_end.phase = blink::WebMouseWheelEvent::kPhaseEnded;
+        rwhier()->RouteMouseWheelEvent(view_root_.get(), &wheel_end,
+                                       ui::LatencyInfo());
+      }));
+
+  rwhier()->OnRenderWidgetHostViewInputDestroyed(child.view.get());
+  EXPECT_TRUE(ack_callback_ran);
+  EXPECT_FALSE(targeter->is_auto_scroll_in_progress());
+}
+
 #if defined(USE_AURA)
 // Mock the DelegatedInkPointRenderer to grab the delegated ink points as they
 // are shipped off to viz from the browser process.
diff --git a/content/browser/renderer_host/render_widget_host_view_aura_unittest.cc b/content/browser/renderer_host/render_widget_host_view_aura_unittest.cc
index 7e276aa..ec8d5df 100644
--- a/content/browser/renderer_host/render_widget_host_view_aura_unittest.cc
+++ b/content/browser/renderer_host/render_widget_host_view_aura_unittest.cc
@@ -37,6 +37,7 @@
 #include "components/input/mouse_wheel_event_queue.h"
 #include "components/input/native_web_keyboard_event.h"
 #include "components/input/render_widget_host_view_input.h"
+#include "components/input/render_widget_host_view_input_observer.h"
 #include "components/viz/common/features.h"
 #include "components/viz/common/frame_sinks/begin_frame_args.h"
 #include "components/viz/common/surfaces/child_local_surface_id_allocator.h"
@@ -99,6 +100,7 @@
 #include "ui/aura/window.h"
 #include "ui/aura/window_event_dispatcher.h"
 #include "ui/aura/window_observer.h"
+#include "ui/aura/window_tracker.h"
 #include "ui/aura/window_tree_host.h"
 #include "ui/base/clipboard/clipboard.h"
 #include "ui/base/clipboard/test/clipboard_test_util.h"
@@ -8126,4 +8128,57 @@
   EXPECT_TRUE(parent_delegate.fired());
 }
 
+// Regression test for crbug.com/567673405. An observer notified during
+// DestroyOrDefer() may synchronously dispatch input to the view, taking and
+// releasing a ScopedInputDispatchPin. Releasing that pin must not delete the
+// view while DestroyOrDefer() is still on the stack; otherwise the remainder
+// of teardown would use a freed object, and DestroyImpl() would run twice.
+TEST_F(RenderWidgetHostViewAuraTest,
+       PinReleasedDuringDestroyNotificationDoesNotDestroyEarly) {
+  InitViewForFrame(nullptr);
+  aura::client::ParentWindowWithContext(
+      view_->GetNativeView(), aura_test_helper_->GetContext(), gfx::Rect(),
+      display::kInvalidDisplayId);
+
+  class PinningObserver : public input::RenderWidgetHostViewInputObserver {
+   public:
+    explicit PinningObserver(aura::WindowTracker* tracker)
+        : tracker_(tracker) {}
+
+    void OnRenderWidgetHostViewInputDestroyed(
+        input::RenderWidgetHostViewInput* view) override {
+      view->RemoveObserver(this);
+      aura::Window* window =
+          static_cast<RenderWidgetHostViewAura*>(view)->GetNativeView();
+      // Simulate a synchronous input dispatch to |view| during teardown.
+      {
+        input::ScopedInputDispatchPin pin(view);
+      }
+      // DestroyImpl() deletes the native window; it must not have run yet.
+      notified_ = true;
+      window_survived_unpin_ = tracker_->Contains(window);
+    }
+
+    bool notified() const { return notified_; }
+    bool window_survived_unpin() const { return window_survived_unpin_; }
+
+   private:
+    raw_ptr<aura::WindowTracker> tracker_;
+    bool notified_ = false;
+    bool window_survived_unpin_ = false;
+  };
+
+  aura::Window* window = view_->GetNativeView();
+  aura::WindowTracker tracker({window});
+  PinningObserver observer(&tracker);
+  view_->AddObserver(&observer);
+
+  view_.ExtractAsDangling()->DestroyOrDefer();
+
+  EXPECT_TRUE(observer.notified());
+  EXPECT_TRUE(observer.window_survived_unpin());
+  // DestroyImpl() ran once DestroyOrDefer() finished, deleting the window.
+  EXPECT_FALSE(tracker.Contains(window));
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.