CVE-2026-79023
Overview
Background
- `IdleSpellCheckController`
- Blink’s renderer-side scheduler for spell checking editable content. It has an active/inactive state, a “hot mode” pass after edits, and a “cold mode” pass during idle time, and sends requests through
spell_check_requester_. - Hot mode invocation
IdleSpellCheckController::HotModeInvocationwalks everyUndoStepnewer thanlast_processed_undo_step_sequence_and issues a spell-check request at each step’s ending selection, according to the commit message.- `UndoStack` / `UndoStep::SequenceNumber()`
- the frame
Editor’s list of undoable edits. Each step has a monotonically increasing sequence number, which the controller uses as a watermark. - Deactivation
- per the regression test, an edit such as
execCommand("insertHTML")made without a user gesture leaves the controller inState::kInactivethroughDeactivate(), which is meant to stop spell checking for that edit.
Root Cause Analysis
IdleSpellCheckController decides what to spell-check in hot mode by looking at undo steps newer than the watermark last_processed_undo_step_sequence_, but before this patch the watermark only advanced inside HotModeInvocation itself. When the controller was deactivated, for example because script inserted content with execCommand without a user gesture, Deactivate() shut down the cold-mode requester, the idle callback and spell_check_requester_, but left the watermark where it was. The undo steps registered while inactive therefore stayed “unprocessed”. The next hot mode pass after the controller was reactivated picked them up and issued spell-check requests at their ending selections. That is an edit-provenance authorization failure: text the controller had deliberately declined to check (no user gesture) was checked later anyway.
The fix adds a block at the end of Deactivate() that, when an execution context and frame exist and the SkipStaleUndoStepsInIdleSpellCheck runtime feature (status stable) is enabled, advances last_processed_undo_step_sequence_ to at least the newest undo step’s SequenceNumber(). Later hot mode passes then only see steps registered after reactivation.
Deactivate() makes deactivation actually exclude those edits.Attack Path
- Editable content on attacker page
A page contains a
contenteditableelement withspellcheck='true'(as in the regression test), which ordinary web content can create. - Script edit without user gesture
Script focuses the element and runs an editing command such as
execCommand("insertHTML", ...)without user activation. This records anUndoStep, and the controller goeskInactivethroughDeactivate()without advancing the watermark. - Reactivation Later, an action that reactivates the controller (for example a real user edit or selection change in the editable region) triggers a hot mode pass (inferred, since the patch does not show the reactivation trigger).
- Stale steps checked
HotModeInvocationwalks the stale undo step and requests spell checking of the script-inserted text at its ending selection, which the gesture gate was meant to prevent. Any information the page can then observe, or any text sent to a spelling backend, is (inferred).
Impact Assessment
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/platform/runtime_enabled_features.json5third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.ccthird_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.hthird_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc
Audit Directions
- Watermarks advanced on only one pathLook for “last processed” sequence numbers, cursors or timestamps that only advance on the consuming path. If a disable, cancel or deactivate path leaves them alone, work queued while disabled will be processed after re-enable. Check the other
IdleSpellCheckControllerpaths (cold mode,RespondToChangedSelection) and similar idle-task controllers. - User-gesture gates enforced at the wrong timeWhere Blink skips work for non-user-initiated edits (spellcheck, autocorrect, text-input suggestions, IME), check that the gate is attached to the edit record itself rather than only to the controller’s current state, so a later state change cannot bring old edits back.
- Kill-switched security fixesThis fix is behind
SkipStaleUndoStepsInIdleSpellCheck. Check that such runtime features default tostable, and that no enterprise policy, field trial or embedder override can turn them off unintentionally.
Patch
From 899e29e0e50065f55035b9c6a6789174635a7215 Mon Sep 17 00:00:00 2001 From: Koji Ishii <[email protected]> Date: Tue, 21 Jul 2026 15:16:00 -0700 Subject: [PATCH] [spellcheck] Skip stale undo steps after Deactivate() `IdleSpellCheckController::HotModeInvocation` walks every undo step newer than `last_processed_undo_step_sequence_` and issues a spell-check request at each step's ending selection. The watermark was only advanced inside `HotModeInvocation` itself, so undo steps registered while the controller was inactive were still picked up by the next hot mode pass. This patch advances the watermark in `Deactivate()` so that a later hot mode invocation only checks undo steps registered after the controller was reactivated. Fixed: 517761566 Change-Id: I5423be92ed307d671fd19f1f1e987aaae791f83a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8130837 Commit-Queue: Kent Tamura <[email protected]> Reviewed-by: Kent Tamura <[email protected]> Auto-Submit: Koji Ishii <[email protected]> Cr-Commit-Position: refs/heads/main@{#1665831} --- diff --git a/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc b/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc index 3f20f0e..bf4325f 100644 --- a/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc +++ b/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc @@ -114,6 +114,20 @@ cold_mode_requester_->Deactivate(); DisposeIdleCallback(); spell_check_requester_->Deactivate(); + + // Advance the undo step sequence so that a later hot mode invocation only + // checks undo steps registered after the controller was reactivated. + if (GetExecutionContext() && + RuntimeEnabledFeatures::SkipStaleUndoStepsInIdleSpellCheckEnabled()) { + if (const LocalFrame* frame = GetWindow().GetFrame()) { + const auto undo_steps = frame->GetEditor().GetUndoStack().UndoSteps(); + if (undo_steps.begin() != undo_steps.end()) { + last_processed_undo_step_sequence_ = + std::max(last_processed_undo_step_sequence_, + (*undo_steps.begin())->SequenceNumber()); + } + } + } } void IdleSpellCheckController::RespondToChangedSelection() { diff --git a/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.h b/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.h index 7784a4c0..5d12003 100644 --- a/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.h +++ b/third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.h @@ -69,6 +69,9 @@ void SetNeedsMoreColdModeInvocationForTesting(); void SkipColdModeTimerForTesting(); int IdleCallbackHandle() const { return idle_callback_handle_; } + uint64_t LastProcessedUndoStepSequenceForTesting() const { + return last_processed_undo_step_sequence_; + } void Trace(Visitor*) const override; diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5 index 44857577..3e25fed 100644 --- a/third_party/blink/renderer/platform/runtime_enabled_features.json5 +++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5 @@ -5892,6 +5892,11 @@ status: "stable", }, { + // crbug.com/517761566 + name: "SkipStaleUndoStepsInIdleSpellCheck", + status: "stable", + }, + { // Skips the browser touch event filter, ensuring that events that reach // the queue and would otherwise be filtered out will instead be passed // onto the renderer compositor process as long as the page hasn't timed 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 bea53fa..ac2a2db 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 @@ -8,6 +8,9 @@ #include "third_party/blink/public/common/features.h" #include "third_party/blink/renderer/core/dom/element.h" +#include "third_party/blink/renderer/core/editing/commands/undo_stack.h" +#include "third_party/blink/renderer/core/editing/commands/undo_step.h" +#include "third_party/blink/renderer/core/editing/editor.h" #include "third_party/blink/renderer/core/editing/frame_selection.h" #include "third_party/blink/renderer/core/editing/selection_template.h" #include "third_party/blink/renderer/core/editing/spellcheck/spell_check_test_base.h" @@ -16,6 +19,8 @@ #include "third_party/blink/renderer/core/frame/local_frame.h" #include "third_party/blink/renderer/core/html/html_object_element.h" #include "third_party/blink/renderer/core/keywords.h" +#include "third_party/blink/renderer/platform/bindings/exception_state.h" +#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h" namespace blink { @@ -374,4 +379,55 @@ } } +TEST_P(IdleSpellCheckControllerTest, StaleUndoStepSkippedOnDeactivate) { + if (IsUnrestricted()) { + return; + } + + SetBodyContent( + "<div id='div1' contenteditable='true' spellcheck='true'>foo</div>"); + UpdateAllLifecyclePhasesForTest(); + + Element* div1 = QuerySelector("#div1"); + div1->Focus(FocusParams(SelectionBehaviorOnFocus::kRestore, + mojom::blink::FocusType::kScript, + /*capabilities=*/nullptr)); + GetDocument().execCommand("insertHTML", false, "bar", ASSERT_NO_EXCEPTION); + UpdateAllLifecyclePhasesForTest(); + + // Since execCommand was executed without a user gesture, the controller + // is deactivated and the watermark is advanced to the sequence number + // of the newly registered UndoStep. + EXPECT_EQ(State::kInactive, IdleChecker().GetState()); + auto undo_steps = + GetDocument().GetFrame()->GetEditor().GetUndoStack().UndoSteps(); + ASSERT_NE(undo_steps.begin(), undo_steps.end()); + uint64_t last_step_seq = (*undo_steps.begin())->SequenceNumber(); + EXPECT_GE(IdleChecker().LastProcessedUndoStepSequenceForTesting(), + last_step_seq); +} + +TEST_P(IdleSpellCheckControllerTest, + StaleUndoStepNotSkippedWhenFeatureDisabled) { + if (IsUnrestricted()) { + return; + } + + ScopedSkipStaleUndoStepsInIdleSpellCheckForTest feature(false); + + SetBodyContent( + "<div id='div1' contenteditable='true' spellcheck='true'>foo</div>"); + UpdateAllLifecyclePhasesForTest(); + + Element* div1 = QuerySelector("#div1"); + div1->Focus(FocusParams(SelectionBehaviorOnFocus::kRestore, + mojom::blink::FocusType::kScript, + /*capabilities=*/nullptr)); + GetDocument().execCommand("insertHTML", false, "bar", ASSERT_NO_EXCEPTION); + UpdateAllLifecyclePhasesForTest(); + + EXPECT_EQ(State::kInactive, IdleChecker().GetState()); + EXPECT_EQ(0u, IdleChecker().LastProcessedUndoStepSequenceForTesting()); +} + } // 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 bea53fa..ac2a2db 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
@@ -8,6 +8,9 @@
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/renderer/core/dom/element.h"
+#include "third_party/blink/renderer/core/editing/commands/undo_stack.h"
+#include "third_party/blink/renderer/core/editing/commands/undo_step.h"
+#include "third_party/blink/renderer/core/editing/editor.h"
#include "third_party/blink/renderer/core/editing/frame_selection.h"
#include "third_party/blink/renderer/core/editing/selection_template.h"
#include "third_party/blink/renderer/core/editing/spellcheck/spell_check_test_base.h"
@@ -16,6 +19,8 @@
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/html/html_object_element.h"
#include "third_party/blink/renderer/core/keywords.h"
+#include "third_party/blink/renderer/platform/bindings/exception_state.h"
+#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
namespace blink {
@@ -374,4 +379,55 @@
}
}
+TEST_P(IdleSpellCheckControllerTest, StaleUndoStepSkippedOnDeactivate) {
+ if (IsUnrestricted()) {
+ return;
+ }
+
+ SetBodyContent(
+ "<div id='div1' contenteditable='true' spellcheck='true'>foo</div>");
+ UpdateAllLifecyclePhasesForTest();
+
+ Element* div1 = QuerySelector("#div1");
+ div1->Focus(FocusParams(SelectionBehaviorOnFocus::kRestore,
+ mojom::blink::FocusType::kScript,
+ /*capabilities=*/nullptr));
+ GetDocument().execCommand("insertHTML", false, "bar", ASSERT_NO_EXCEPTION);
+ UpdateAllLifecyclePhasesForTest();
+
+ // Since execCommand was executed without a user gesture, the controller
+ // is deactivated and the watermark is advanced to the sequence number
+ // of the newly registered UndoStep.
+ EXPECT_EQ(State::kInactive, IdleChecker().GetState());
+ auto undo_steps =
+ GetDocument().GetFrame()->GetEditor().GetUndoStack().UndoSteps();
+ ASSERT_NE(undo_steps.begin(), undo_steps.end());
+ uint64_t last_step_seq = (*undo_steps.begin())->SequenceNumber();
+ EXPECT_GE(IdleChecker().LastProcessedUndoStepSequenceForTesting(),
+ last_step_seq);
+}
+
+TEST_P(IdleSpellCheckControllerTest,
+ StaleUndoStepNotSkippedWhenFeatureDisabled) {
+ if (IsUnrestricted()) {
+ return;
+ }
+
+ ScopedSkipStaleUndoStepsInIdleSpellCheckForTest feature(false);
+
+ SetBodyContent(
+ "<div id='div1' contenteditable='true' spellcheck='true'>foo</div>");
+ UpdateAllLifecyclePhasesForTest();
+
+ Element* div1 = QuerySelector("#div1");
+ div1->Focus(FocusParams(SelectionBehaviorOnFocus::kRestore,
+ mojom::blink::FocusType::kScript,
+ /*capabilities=*/nullptr));
+ GetDocument().execCommand("insertHTML", false, "bar", ASSERT_NO_EXCEPTION);
+ UpdateAllLifecyclePhasesForTest();
+
+ EXPECT_EQ(State::kInactive, IdleChecker().GetState());
+ EXPECT_EQ(0u, IdleChecker().LastProcessedUndoStepSequenceForTesting());
+}
+
} // namespace blink
Original Bug Report
Bypass of user-dictionary-leaks mitigation via unvalidated historical UndoStack steps
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: The user-dictionary-leaks mitigation in Blink is designed to prevent malicious websites from programmatically querying the user’s custom spellcheck dictionary by gating idle spellchecking behind transient user activation. However, this mitigation can potentially be bypassed because programmatic edit commands executed without a user gesture are stored in the frame’s UndoStack and subsequently spellchecked during a future HotModeInvocation triggered by a single legitimate user click. This allows a malicious page to pre-seed the UndoStack with candidate words and exfiltrate dictionary contents once the user interacts with the page.
Affected files:
third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.ccthird_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.hthird_party/blink/renderer/core/editing/spellcheck/hot_mode_spell_check_requester.cc
Estimated timestamp from git blame: 2025-10-23
Root Cause
In third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc, the user-dictionary-leaks mitigation is implemented by checking for transient user activation before scheduling a hot-mode spellcheck. If LocalFrame::HasTransientUserActivation returns false during a content change, the controller is deactivated via Deactivate():
void IdleSpellCheckController::RespondToChangedContents() {
if (!IsSpellCheckingEnabled()) {
Deactivate();
return;
}
if (!LocalFrame::HasTransientUserActivation(GetWindow().GetFrame()) &&
!base::FeatureList::IsEnabled(
features::kUnrestrictSpellingAndGrammarForTesting)) {
Deactivate();
return;
}
...
}
However, programmatic edit commands (such as document.execCommand('insertHTML', ...)) still successfully create and register UndoSteps in the frame’s shared UndoStack (via UndoStack::RegisterUndoStep()). When Deactivate() is called on the controller, the watermark last_processed_undo_step_sequence_ is not updated or advanced, leaving it at 0.
Once the user later performs a legitimate click or interaction anywhere on the page, the frame is granted transient user activation. The next selection or content change event passes the user-gesture check and triggers HotModeInvocation(). During this invocation, the controller loops through all historical steps in the UndoStack:
const uint64_t watermark = last_processed_undo_step_sequence_;
for (const UndoStep* step :
GetWindow().GetFrame()->GetEditor().GetUndoStack().UndoSteps()) {
if (step->SequenceNumber() <= watermark)
break;
last_processed_undo_step_sequence_ =
std::max(step->SequenceNumber(), last_processed_undo_step_sequence_);
if (deadline->timeRemaining() == 0)
break;
if (!step->EndingSelection().IsValidFor(GetDocument()))
continue;
requester.CheckSpellingAt(step->EndingSelection().Focus());
}
Because the watermark is still at 0 (or its previous value before deactivation), any programmatic UndoSteps generated while the controller was deactivated have sequence numbers greater than the watermark. As a result, the loop sweeps up and spellchecks these programmatic edits via requester.CheckSpellingAt() without any further user gesture or transient activation validation.
Potential Attack Scenario
(Note: These are potential steps as our tooling agent does not have the capability to execute code in a live environment)
- Pre-seeding: A malicious webpage programmatically focuses a hidden
<div contenteditable spellcheck="true">element and executesdocument.execCommand('insertHTML', false, 'probe_word')to insert various candidate words. This populates theUndoStackwith multiple historical edit steps while keeping the watermarklast_processed_undo_step_sequence_at0. - User Interaction: The page induces the user to perform a single click anywhere on the page (e.g., clicking a decoy button or a generic area), granting the frame transient user activation.
- Bypass Trigger: The user gesture schedules
HotModeInvocation(), which iterates through theUndoStackand triggers spellchecking for all of the pre-seeded candidate words. - Exfiltration: The page detects which words were flagged as correct (present in the user’s custom dictionary) by observing rendering or timing side channels (e.g., measuring delayed repaint frames using
requestAnimationFrame()for misspelled words, or measuring CPU suggestion-generation delays usingperformance.now()).
Suggested Fix
To remediate this issue, the controller should prevent processing any historical UndoSteps that were registered when there was no active transient user activation. This can be resolved by updating the watermark last_processed_undo_step_sequence_ to the latest step’s sequence number whenever the controller is deactivated or whenever an edit command is registered without user activation. Alternatively, we can record the transient user activation state at the time of UndoStep registration and only perform spellchecking on steps that had a valid user activation.
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.