Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Editing
DescriptionIncorrect authorization in Editing
ComponentEditing
Sub-componentSpellcheck
Bug ClassAuthorization Error
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.
Tracker517761566
Fix commit899e29e0e500 (chromium/src) +78/-0
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-07-21 (public git)
Disclosed2026-08-25
Patch gap35 days — fix public in source before the advisory

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::HotModeInvocation walks every UndoStep newer than last_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 in State::kInactive through Deactivate(), 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.

Key insight
The “already processed” watermark was advanced only on the hot-mode path, so deactivating the controller did not mark edits made while inactive as excluded, and they were spell-checked once checking resumed. Advancing the watermark in Deactivate() makes deactivation actually exclude those edits.

Attack Path

  1. Editable content on attacker page A page contains a contenteditable element with spellcheck='true' (as in the regression test), which ordinary web content can create.
  2. Script edit without user gesture Script focuses the element and runs an editing command such as execCommand("insertHTML", ...) without user activation. This records an UndoStep, and the controller goes kInactive through Deactivate() without advancing the watermark.
  3. 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).
  4. Stale steps checked HotModeInvocation walks 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

A web page can get script-inserted, non-user-gesture text spell-checked in the renderer’s spellcheck pipeline, bypassing the intended rule that only user-originated edits are hot-mode checked. The security consequence is not shown in the patch. Plausible effects include page-chosen text reaching the platform or enhanced spell-check service, or spelling-marker side channels about the user’s dictionary (inferred). It requires only ordinary web content plus a later reactivation of spell checking, which most likely needs user interaction (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5.

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/platform/runtime_enabled_features.json5
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.h
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc

Audit Directions

  • Watermarks advanced on only one path
    Look 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 IdleSpellCheckController paths (cold mode, RespondToChangedSelection) and similar idle-task controllers.
  • User-gesture gates enforced at the wrong time
    Where 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 fixes
    This fix is behind SkipStaleUndoStepsInIdleSpellCheck. Check that such runtime features default to stable, and that no enterprise policy, field trial or embedder override can turn them off unintentionally.
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
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 [email protected]

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