Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Editing
DescriptionIncorrect authorization in Editing
ComponentEditing
Bug ClassLogic Error
Tracker517761566
Fix commit899e29e0e500 (chromium/src) +78/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

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/spellcheck/idle_spell_check_controller.cc
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.h
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc
  • third_party/blink/renderer/platform/runtime_enabled_features.json5
From 899e29e0e50065f55035b9c6a6789174635a7215 Mon Sep 17 00:00:00 2001
From: Koji Ishii <kojii@chromium.org>
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 <tkent@chromium.org>
Reviewed-by: Kent Tamura <tkent@chromium.org>
Auto-Submit: Koji Ishii <kojii@chromium.org>
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/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
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
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 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
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.h
  • third_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)

  1. Pre-seeding: A malicious webpage programmatically focuses a hidden <div contenteditable spellcheck="true"> element and executes document.execCommand('insertHTML', false, 'probe_word') to insert various candidate words. This populates the UndoStack with multiple historical edit steps while keeping the watermark last_processed_undo_step_sequence_ at 0.
  2. 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.
  3. Bypass Trigger: The user gesture schedules HotModeInvocation(), which iterates through the UndoStack and triggers spellchecking for all of the pre-seeded candidate words.
  4. 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 using performance.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.

View on issue tracker