Low watch CVSS 5.3 chrome Race 🔧 Commit mapped

Overview

Low
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Editing
DescriptionRace condition in Editing
ComponentEditing
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker502252964
Fix commit4217ca7b76c7 (chromium/src) +39/-1
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-13 (per vendor advisory)
Fix landed2026-07-08 (public git)
Disclosed2026-08-25
Patch gap48 days — fix public in source before the advisory

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 Editor method 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.

Key insight
The core mistake was invoking 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

  1. Focus an editable element via gesture A page focuses a contenteditable element (e.g. div1) with a user gesture, priming the idle spell-check controller into kHotModeRequested.
  2. Programmatically change the selection Script calls Selection().SetSelection(...) to collapse the selection into a different editable element (div2) without a user gesture.
  3. 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 selected div2, producing an incorrect controller state and acting on a selection that no longer matches the DOM. (inferred)
  4. Observable inconsistency The mismatch between the spell checker’s view and the committed selection manifests as the wrong controller state (the test contrasts kHotModeRequested vs kInactive), the ordering defect this patch corrects.

Impact Assessment

The defect is an ordering/race condition in editing whereby the spell checker responds to a selection change before the change is applied, causing it to operate on stale selection/focus state; an attacker page can steer this via programmatic selection changes across editable elements. The advisory classifies it as a race condition in Editing with no exploited-in-the-wild status stated, and no memory-safety primitive is demonstrated by the diff; any escalation beyond incorrect spell-check behavior is (inferred).

Changed Functions

FunctionChangeNotes
TEST_P
third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/editing/frame_selection.cc
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc

Audit Directions

  • Side effects fired before state commit
    Look 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 ordering
    Audit other FrameSelection and Editor call 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 gates
    Review 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.
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
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.