CVE-2026-87631
Overview
Background
- Focusgroup
- a Blink DOM feature (the
focusgroupattribute) that lets arrow keys move focus among a set of related focusable elements. - `FocusgroupController`
- the Blink controller whose
HandleKeyboardEventinterprets keydown events and performs forward/backward focus navigation within a focusgroup. - `Event::isTrusted()`
- a flag that is
trueonly for events the user agent itself dispatched, andfalsefor events constructed and dispatched by script. - `WasLastFocusFromUserGesture()`
- a per-element flag recording whether the element’s most recent focus was the result of a genuine user gesture, which downstream features consult as an authorization signal.
Root Cause Analysis
The changed function (FocusgroupController::HandleKeyboardEvent, whose signature (..., const LocalFrame* frame) with CHECK(frame) / CHECK(frame->DomWindow()) matches the function the added test calls directly — the @@ header does not name it) processed every keydown event that reached it without verifying the event was trusted. As the commit message states, the controller “accepted script-created keydown events and moved focus with a forward or backward focus type, marking the target as focused from a user gesture.” A page could therefore construct a synthetic KeyboardEvent (whose isTrusted() is false), dispatch it into a focusgroup, and cause FocusgroupController to both move focus and set the target’s WasLastFocusFromUserGesture() flag. The violated invariant is that focus navigation and user-gesture attribution must originate only from genuine user input, not from dispatchEvent.
The fix inserts if (!event->isTrusted()) { return false; } immediately after the two CHECKs and before any of the feature-enablement or navigation logic, so an untrusted event exits early with no focus change and no gesture marking. This tightens the check: it adds a guard that did not previously exist.
FocusgroupController::HandleKeyboardEvent trusted any keydown event, including script-forged ones, letting a page spoof a user-gesture-driven focus move; the fix rejects events for which isTrusted() is false before any navigation runs.Attack Path
- Build a focusgroup
The attacker page contains a
focusgroupcontainer holding focusable items, including a sensitive target such as acontenteditable/spellcheckelement, with an element inside it focused. - Forge a keydown
Script constructs an untrusted
KeyboardEventof typekeydownwithkeyset toArrowDownorArrowUp, soisTrusted()returnsfalse. - Dispatch into the focusgroup
Dispatching the event to the focused element routes it to
FocusgroupController::HandleKeyboardEvent, which pre-patch processed it as if it were real user input. - Spoof user-gesture focus
Focus moves to the adjacent focusgroup item and that element’s
WasLastFocusFromUserGesture()becomestrue, forging a user-gesture attribution without any user interaction. - Abuse the spoofed gesture (inferred) Behaviors gated on user-gesture-initiated focus can be reached without a genuine gesture; the patch demonstrates only the focus move and gesture-flag spoof, not any specific downstream escalation.
Impact Assessment
focusgroup feature. This defeats an authorization signal meant to distinguish real user input from script, matching the reported class (Missing authorization in DOM). Any escalation beyond the focus move and user-gesture spoof the patch demonstrates is (inferred), and this analysis makes no exploited-in-the-wild or severity claim beyond what the patch shows.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/page/focusgroup_controller_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/page/focusgroup_controller_test.ccthird_party/blink/renderer/core/page/focusgroup_controller.cc
Audit Directions
- Missing `isTrusted()` guardsReview other event-driven controllers reachable via
dispatchEvent(keyboard, pointer, focus handlers) that perform navigation or state changes, and confirm they reject events for whichisTrusted()isfalse. - User-gesture attribution from eventsAudit any code path that sets
WasLastFocusFromUserGesture()or user-activation state as a side effect of handling an event, ensuring the event’s trustedness is validated first. - Synthetic-event reachabilityLook for DOM features that assume incoming events are user-generated, and verify script cannot forge the same effect by constructing and dispatching a matching event.
Patch
From 78f4142f505e1aeca80ce1bff1dfb1f3737d122a Mon Sep 17 00:00:00 2001 From: Jacques Newman <[email protected]> Date: Wed, 29 Jul 2026 21:07:04 -0700 Subject: [PATCH] [focusgroup] Ignore untrusted keyboard navigation FocusgroupController accepted script-created keydown events and moved focus with a forward or backward focus type, marking the target as focused from a user gesture. Reject untrusted keyboard events before focusgroup navigation. Add coverage for both navigation directions while preserving trusted keyboard behavior. Fixed: 537476242 Bug: 40210717 Change-Id: Ie75fc020926ecb8f143dd00534a685a3951e222b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8162543 Commit-Queue: Jacques Newman <[email protected]> Reviewed-by: Mason Freed <[email protected]> Cr-Commit-Position: refs/heads/main@{#1670818} --- diff --git a/third_party/blink/renderer/core/page/focusgroup_controller.cc b/third_party/blink/renderer/core/page/focusgroup_controller.cc index e9a3d8e..c7fadff 100644 --- a/third_party/blink/renderer/core/page/focusgroup_controller.cc +++ b/third_party/blink/renderer/core/page/focusgroup_controller.cc @@ -29,6 +29,9 @@ const LocalFrame* frame) { CHECK(frame); CHECK(frame->DomWindow()); + if (!event->isTrusted()) { + return false; + } ExecutionContext* context = frame->DomWindow()->GetExecutionContext(); if (!RuntimeEnabledFeatures::FocusgroupEnabled(context)) { return false; diff --git a/third_party/blink/renderer/core/page/focusgroup_controller_test.cc b/third_party/blink/renderer/core/page/focusgroup_controller_test.cc index ccd2b630..7c40d86 100644 --- a/third_party/blink/renderer/core/page/focusgroup_controller_test.cc +++ b/third_party/blink/renderer/core/page/focusgroup_controller_test.cc @@ -8,6 +8,7 @@ #include "testing/gtest/include/gtest/gtest.h" #include "third_party/blink/public/common/input/web_keyboard_event.h" +#include "third_party/blink/renderer/bindings/core/v8/v8_keyboard_event_init.h" #include "third_party/blink/renderer/core/dom/document.h" #include "third_party/blink/renderer/core/dom/shadow_root.h" #include "third_party/blink/renderer/core/events/keyboard_event.h" @@ -16,6 +17,7 @@ #include "third_party/blink/renderer/core/html/html_dialog_element.h" #include "third_party/blink/renderer/core/html/html_element.h" #include "third_party/blink/renderer/core/input/event_handler.h" +#include "third_party/blink/renderer/core/keywords.h" #include "third_party/blink/renderer/core/page/focusgroup_controller_utils.h" #include "third_party/blink/renderer/core/page/grid_focusgroup_structure_info.h" #include "third_party/blink/renderer/core/testing/page_test_base.h" @@ -46,6 +48,16 @@ return event; } + KeyboardEvent* UntrustedKeyDownEvent(const String& key, Element* target) { + KeyboardEventInit* init = KeyboardEventInit::Create(); + init->setBubbles(true); + init->setKey(key); + auto* event = + MakeGarbageCollected<KeyboardEvent>(event_type_names::kKeydown, init); + event->SetTarget(target); + return event; + } + void SendEvent(KeyboardEvent* event) { if (event->target()) { event->target()->DispatchEvent(*event); @@ -1110,6 +1122,100 @@ ASSERT_EQ(GetDocument().FocusedElement(), item1); } +TEST_F(FocusgroupControllerTest, + UntrustedArrowDownDoesNotMoveFocusOrMarkUserGesture) { + GetDocument().body()->SetInnerHTMLWithoutTrustedTypes(R"HTML( + <div focusgroup="menu block"> + <div id=first tabindex=0>First</div> + <div id=spellcheck tabindex=0 contenteditable spellcheck=true> + Spellcheck target + </div> + </div> + )HTML"); + UpdateAllLifecyclePhasesForTest(); + + auto* first = GetElementById("first"); + auto* spellcheck = GetElementById("spellcheck"); + ASSERT_TRUE(first); + ASSERT_TRUE(spellcheck); + + first->Focus(); + ASSERT_EQ(GetDocument().FocusedElement(), first); + ASSERT_FALSE(spellcheck->WasLastFocusFromUserGesture()); + + auto* event = UntrustedKeyDownEvent(keywords::kArrowDown, first); + ASSERT_FALSE(event->isTrusted()); + EXPECT_FALSE(FocusgroupController::HandleKeyboardEvent( + event, GetDocument().GetFrame())); + EXPECT_EQ(GetDocument().FocusedElement(), first); + EXPECT_FALSE(spellcheck->WasLastFocusFromUserGesture()); +} + +TEST_F(FocusgroupControllerTest, + UntrustedArrowUpDoesNotMoveFocusOrMarkUserGesture) { + GetDocument().body()->SetInnerHTMLWithoutTrustedTypes(R"HTML( + <div focusgroup="menu block"> + <div id=spellcheck tabindex=0 contenteditable spellcheck=true> + Spellcheck target + </div> + <div id=last tabindex=0>Last</div> + </div> + )HTML"); + UpdateAllLifecyclePhasesForTest(); + + auto* spellcheck = GetElementById("spellcheck"); + auto* last = GetElementById("last"); + ASSERT_TRUE(spellcheck); + ASSERT_TRUE(last); + + last->Focus(); + ASSERT_EQ(GetDocument().FocusedElement(), last); + ASSERT_FALSE(spellcheck->WasLastFocusFromUserGesture()); + + auto* event = UntrustedKeyDownEvent(keywords::kArrowUp, last); + ASSERT_FALSE(event->isTrusted()); + EXPECT_FALSE(FocusgroupController::HandleKeyboardEvent( + event, GetDocument().GetFrame())); + EXPECT_EQ(GetDocument().FocusedElement(), last); + EXPECT_FALSE(spellcheck->WasLastFocusFromUserGesture()); +} + +TEST_F(FocusgroupControllerTest, TrustedArrowKeysMoveFocusAndMarkUserGesture) { + GetDocument().body()->SetInnerHTMLWithoutTrustedTypes(R"HTML( + <div focusgroup="menu block"> + <div id=first tabindex=0>First</div> + <div id=spellcheck tabindex=0 contenteditable spellcheck=true> + Spellcheck target + </div> + <div id=last tabindex=0>Last</div> + </div> + )HTML"); + UpdateAllLifecyclePhasesForTest(); + + auto* first = GetElementById("first"); + auto* spellcheck = GetElementById("spellcheck"); + auto* last = GetElementById("last"); + ASSERT_TRUE(first); + ASSERT_TRUE(spellcheck); + ASSERT_TRUE(last); + + first->Focus(); + ASSERT_EQ(GetDocument().FocusedElement(), first); + ASSERT_FALSE(spellcheck->WasLastFocusFromUserGesture()); + + SendArrowDown(first); + EXPECT_EQ(GetDocument().FocusedElement(), spellcheck); + EXPECT_TRUE(spellcheck->WasLastFocusFromUserGesture()); + + last->Focus(); + ASSERT_EQ(GetDocument().FocusedElement(), last); + ASSERT_FALSE(spellcheck->WasLastFocusFromUserGesture()); + + SendArrowUp(last); + EXPECT_EQ(GetDocument().FocusedElement(), spellcheck); + EXPECT_TRUE(spellcheck->WasLastFocusFromUserGesture()); +} + TEST_F(FocusgroupControllerTest, NestedFocusgroupsHaveSeparateScopes) { GetDocument().body()->SetHTMLUnsafeWithoutTrustedTypes(R"HTML( <div id=outer focusgroup="toolbar">
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/page/focusgroup_controller_test.cc b/third_party/blink/renderer/core/page/focusgroup_controller_test.cc
index ccd2b630..7c40d86 100644
--- a/third_party/blink/renderer/core/page/focusgroup_controller_test.cc
+++ b/third_party/blink/renderer/core/page/focusgroup_controller_test.cc
@@ -8,6 +8,7 @@
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/input/web_keyboard_event.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_keyboard_event_init.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/dom/shadow_root.h"
#include "third_party/blink/renderer/core/events/keyboard_event.h"
@@ -16,6 +17,7 @@
#include "third_party/blink/renderer/core/html/html_dialog_element.h"
#include "third_party/blink/renderer/core/html/html_element.h"
#include "third_party/blink/renderer/core/input/event_handler.h"
+#include "third_party/blink/renderer/core/keywords.h"
#include "third_party/blink/renderer/core/page/focusgroup_controller_utils.h"
#include "third_party/blink/renderer/core/page/grid_focusgroup_structure_info.h"
#include "third_party/blink/renderer/core/testing/page_test_base.h"
@@ -46,6 +48,16 @@
return event;
}
+ KeyboardEvent* UntrustedKeyDownEvent(const String& key, Element* target) {
+ KeyboardEventInit* init = KeyboardEventInit::Create();
+ init->setBubbles(true);
+ init->setKey(key);
+ auto* event =
+ MakeGarbageCollected<KeyboardEvent>(event_type_names::kKeydown, init);
+ event->SetTarget(target);
+ return event;
+ }
+
void SendEvent(KeyboardEvent* event) {
if (event->target()) {
event->target()->DispatchEvent(*event);
@@ -1110,6 +1122,100 @@
ASSERT_EQ(GetDocument().FocusedElement(), item1);
}
+TEST_F(FocusgroupControllerTest,
+ UntrustedArrowDownDoesNotMoveFocusOrMarkUserGesture) {
+ GetDocument().body()->SetInnerHTMLWithoutTrustedTypes(R"HTML(
+ <div focusgroup="menu block">
+ <div id=first tabindex=0>First</div>
+ <div id=spellcheck tabindex=0 contenteditable spellcheck=true>
+ Spellcheck target
+ </div>
+ </div>
+ )HTML");
+ UpdateAllLifecyclePhasesForTest();
+
+ auto* first = GetElementById("first");
+ auto* spellcheck = GetElementById("spellcheck");
+ ASSERT_TRUE(first);
+ ASSERT_TRUE(spellcheck);
+
+ first->Focus();
+ ASSERT_EQ(GetDocument().FocusedElement(), first);
+ ASSERT_FALSE(spellcheck->WasLastFocusFromUserGesture());
+
+ auto* event = UntrustedKeyDownEvent(keywords::kArrowDown, first);
+ ASSERT_FALSE(event->isTrusted());
+ EXPECT_FALSE(FocusgroupController::HandleKeyboardEvent(
+ event, GetDocument().GetFrame()));
+ EXPECT_EQ(GetDocument().FocusedElement(), first);
+ EXPECT_FALSE(spellcheck->WasLastFocusFromUserGesture());
+}
+
+TEST_F(FocusgroupControllerTest,
+ UntrustedArrowUpDoesNotMoveFocusOrMarkUserGesture) {
+ GetDocument().body()->SetInnerHTMLWithoutTrustedTypes(R"HTML(
+ <div focusgroup="menu block">
+ <div id=spellcheck tabindex=0 contenteditable spellcheck=true>
+ Spellcheck target
+ </div>
+ <div id=last tabindex=0>Last</div>
+ </div>
+ )HTML");
+ UpdateAllLifecyclePhasesForTest();
+
+ auto* spellcheck = GetElementById("spellcheck");
+ auto* last = GetElementById("last");
+ ASSERT_TRUE(spellcheck);
+ ASSERT_TRUE(last);
+
+ last->Focus();
+ ASSERT_EQ(GetDocument().FocusedElement(), last);
+ ASSERT_FALSE(spellcheck->WasLastFocusFromUserGesture());
+
+ auto* event = UntrustedKeyDownEvent(keywords::kArrowUp, last);
+ ASSERT_FALSE(event->isTrusted());
+ EXPECT_FALSE(FocusgroupController::HandleKeyboardEvent(
+ event, GetDocument().GetFrame()));
+ EXPECT_EQ(GetDocument().FocusedElement(), last);
+ EXPECT_FALSE(spellcheck->WasLastFocusFromUserGesture());
+}
+
+TEST_F(FocusgroupControllerTest, TrustedArrowKeysMoveFocusAndMarkUserGesture) {
+ GetDocument().body()->SetInnerHTMLWithoutTrustedTypes(R"HTML(
+ <div focusgroup="menu block">
+ <div id=first tabindex=0>First</div>
+ <div id=spellcheck tabindex=0 contenteditable spellcheck=true>
+ Spellcheck target
+ </div>
+ <div id=last tabindex=0>Last</div>
+ </div>
+ )HTML");
+ UpdateAllLifecyclePhasesForTest();
+
+ auto* first = GetElementById("first");
+ auto* spellcheck = GetElementById("spellcheck");
+ auto* last = GetElementById("last");
+ ASSERT_TRUE(first);
+ ASSERT_TRUE(spellcheck);
+ ASSERT_TRUE(last);
+
+ first->Focus();
+ ASSERT_EQ(GetDocument().FocusedElement(), first);
+ ASSERT_FALSE(spellcheck->WasLastFocusFromUserGesture());
+
+ SendArrowDown(first);
+ EXPECT_EQ(GetDocument().FocusedElement(), spellcheck);
+ EXPECT_TRUE(spellcheck->WasLastFocusFromUserGesture());
+
+ last->Focus();
+ ASSERT_EQ(GetDocument().FocusedElement(), last);
+ ASSERT_FALSE(spellcheck->WasLastFocusFromUserGesture());
+
+ SendArrowUp(last);
+ EXPECT_EQ(GetDocument().FocusedElement(), spellcheck);
+ EXPECT_TRUE(spellcheck->WasLastFocusFromUserGesture());
+}
+
TEST_F(FocusgroupControllerTest, NestedFocusgroupsHaveSeparateScopes) {
GetDocument().body()->SetHTMLUnsafeWithoutTrustedTypes(R"HTML(
<div id=outer focusgroup="toolbar">