CVE-2026-79196
Overview
Background
- `FrameSelection`
- Blink’s per-frame object that tracks and mutates the current DOM selection and coordinates side effects (focus, accessibility, spell check) when the selection changes.
- `RespondToChangedSelection()`
- An
Editormethod invoked on selection change that, among other things, drives the spell checker (via the idle spell-check controller) based on the currently focused/selected editable element. - `IdleSpellCheckController` / hot mode
- The controller that schedules spell checking; “hot mode” is requested for the newly focused editable region, and whether it activates depends on the focused element and how focus was obtained (user gesture vs. programmatic).
- DOM selection update ordering
- Setting a selection involves updating internal selection state and the focused element; consumers that read that state must run after the update is applied to observe the new selection.
Root Cause Analysis
FrameSelection called frame_->GetEditor().RespondToChangedSelection() from a call site that ran before the DOM selection update had actually been applied, so the spell checker was triggered against stale selection/focus state rather than the newly focused element.
The patch removes that premature call (right after is_handle_visible_/ScheduleVisualUpdateForVisualOverflowIfNeeded() and before DCHECK_EQ(current_document, GetDocument())) and re-adds it later, after the block that dispatches the selectionchange event — i.e. after the selection/focus update is in place. The invariant violated was that RespondToChangedSelection must observe the new focused selection; running it early created a race/ordering bug where spell-check state (e.g. kHotModeRequested vs kInactive) was computed from the wrong element, as the regression test ProgrammaticSelectionChangeToNewElement demonstrates by moving selection from div1 to div2 and checking the resulting controller state. Reordering the single call so it fires after the selection has been committed fixes the mismatch.
RespondToChangedSelection() before the DOM selection/focus update was applied, so the spell checker keyed off stale state; the fix moves that call to after the selection update (past the selectionchange dispatch) so it always sees the new focused selection.Attack Path
- Focus an editable element via gesture
A page focuses a
contenteditableelement (e.g.div1) with a user gesture, priming the idle spell-check controller intokHotModeRequested. - Programmatically change the selection
Script calls
Selection().SetSelection(...)to collapse the selection into a different editable element (div2) without a user gesture. - Spell check runs against stale state
Pre-patch,
RespondToChangedSelection()executed before the selection/focus update, so the spell checker evaluated the old focus/selection rather than the newly selecteddiv2, producing an incorrect controller state and acting on a selection that no longer matches the DOM. (inferred) - Observable inconsistency
The mismatch between the spell checker’s view and the committed selection manifests as the wrong controller state (the test contrasts
kHotModeRequestedvskInactive), the ordering defect this patch corrects.
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pthird_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/editing/frame_selection.ccthird_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc
Audit Directions
- Side effects fired before state commitLook for change-notification callbacks (
RespondTo*,DidChange*, observers) invoked before the corresponding DOM/selection/focus state is actually updated, causing consumers to read stale values. - Selection/focus consumers orderingAudit other
FrameSelectionandEditorcall sites that depend on the focused element or current selection to ensure they run after the selection update and event dispatch complete. - Gesture-dependent security/behavior gatesReview logic that branches on
WasLastFocusFromUserGesture()or similar; if the underlying focus/selection is read at the wrong time, such gates can be evaluated against the wrong element.
Patch
From 4217ca7b76c7d688e5d0afddacb93128c1baffd6 Mon Sep 17 00:00:00 2001 From: Stefan Zager <[email protected]> Date: Wed, 08 Jul 2026 15:12:23 -0700 Subject: [PATCH] Verify new focused selection before triggering spell checker The previous call site to RespondToChangedSelection() was _before_ the DOM selection update had been applied. Bug: 502252964 Change-Id: I2852df5e545cd37abeda19c051f61c32962bb08d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8063807 Commit-Queue: Stefan Zager <[email protected]> Reviewed-by: Philip Rogers <[email protected]> Cr-Commit-Position: refs/heads/main@{#1659095} --- diff --git a/third_party/blink/renderer/core/editing/frame_selection.cc b/third_party/blink/renderer/core/editing/frame_selection.cc index 0ada1e6..175c5a8a 100644 --- a/third_party/blink/renderer/core/editing/frame_selection.cc +++ b/third_party/blink/renderer/core/editing/frame_selection.cc @@ -329,7 +329,6 @@ is_handle_visible_ = should_show_handle; ScheduleVisualUpdateForVisualOverflowIfNeeded(); - frame_->GetEditor().RespondToChangedSelection(); DCHECK_EQ(current_document, GetDocument()); return true; } @@ -432,6 +431,7 @@ *Event::Create(event_type_names::kSelectionchange), TaskType::kMiscPlatformAPI); } + frame_->GetEditor().RespondToChangedSelection(); } void FrameSelection::SetSelectionForAccessibility( diff --git a/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc b/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc index 0de18d91..5379da5 100644 --- a/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc +++ b/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc @@ -334,4 +334,42 @@ } } +TEST_P(IdleSpellCheckControllerTest, ProgrammaticSelectionChangeToNewElement) { + IdleChecker().Deactivate(); + SetBodyContent( + "<div id='div1' contenteditable='true' spellcheck='true'>foo</div>" + "<div id='div2' contenteditable='true' spellcheck='true'>bar</div>"); + UpdateAllLifecyclePhasesForTest(); + EXPECT_EQ(State::kInactive, IdleChecker().GetState()); + + // Focus div1 using a mouse user gesture. + Element* div1 = QuerySelector("#div1"); + ASSERT_NE(div1, nullptr); + div1->Focus(FocusParams(SelectionBehaviorOnFocus::kRestore, + mojom::blink::FocusType::kMouse, + /*capabilities=*/nullptr)); + GetDocument().GetFrame()->Selection().SetFrameIsFocused(true); + UpdateAllLifecyclePhasesForTest(); + EXPECT_EQ(State::kHotModeRequested, IdleChecker().GetState()); + EXPECT_EQ(div1, GetDocument().FocusedElement()); + EXPECT_TRUE(div1->WasLastFocusFromUserGesture()); + + // Programmatically change selection to div2 (without user gesture). + Element* div2 = QuerySelector("#div2"); + ASSERT_NE(div2, nullptr); + EXPECT_FALSE(div2->WasLastFocusFromUserGesture()); + + GetDocument().GetFrame()->Selection().SetSelection( + SelectionInDomTree::Builder().Collapse(Position(div2, 0)).Build(), + SetSelectionOptions()); + UpdateAllLifecyclePhasesForTest(); + + EXPECT_EQ(div2, GetDocument().FocusedElement()); + if (IsUnrestricted()) { + EXPECT_EQ(State::kHotModeRequested, IdleChecker().GetState()); + } else { + EXPECT_EQ(State::kInactive, IdleChecker().GetState()); + } +} + } // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc b/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc
index 0de18d91..5379da5 100644
--- a/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc
+++ b/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc
@@ -334,4 +334,42 @@
}
}
+TEST_P(IdleSpellCheckControllerTest, ProgrammaticSelectionChangeToNewElement) {
+ IdleChecker().Deactivate();
+ SetBodyContent(
+ "<div id='div1' contenteditable='true' spellcheck='true'>foo</div>"
+ "<div id='div2' contenteditable='true' spellcheck='true'>bar</div>");
+ UpdateAllLifecyclePhasesForTest();
+ EXPECT_EQ(State::kInactive, IdleChecker().GetState());
+
+ // Focus div1 using a mouse user gesture.
+ Element* div1 = QuerySelector("#div1");
+ ASSERT_NE(div1, nullptr);
+ div1->Focus(FocusParams(SelectionBehaviorOnFocus::kRestore,
+ mojom::blink::FocusType::kMouse,
+ /*capabilities=*/nullptr));
+ GetDocument().GetFrame()->Selection().SetFrameIsFocused(true);
+ UpdateAllLifecyclePhasesForTest();
+ EXPECT_EQ(State::kHotModeRequested, IdleChecker().GetState());
+ EXPECT_EQ(div1, GetDocument().FocusedElement());
+ EXPECT_TRUE(div1->WasLastFocusFromUserGesture());
+
+ // Programmatically change selection to div2 (without user gesture).
+ Element* div2 = QuerySelector("#div2");
+ ASSERT_NE(div2, nullptr);
+ EXPECT_FALSE(div2->WasLastFocusFromUserGesture());
+
+ GetDocument().GetFrame()->Selection().SetSelection(
+ SelectionInDomTree::Builder().Collapse(Position(div2, 0)).Build(),
+ SetSelectionOptions());
+ UpdateAllLifecyclePhasesForTest();
+
+ EXPECT_EQ(div2, GetDocument().FocusedElement());
+ if (IsUnrestricted()) {
+ EXPECT_EQ(State::kHotModeRequested, IdleChecker().GetState());
+ } else {
+ EXPECT_EQ(State::kInactive, IdleChecker().GetState());
+ }
+}
+
} // namespace blink