Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in DOM
DescriptionIncorrect authorization in DOM
ComponentDOM
Bug ClassLogic Error
Tracker517398863
Fix commit52027603880f (chromium/src) +45/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/dom/element.cc
modified
TEST_F
third_party/blink/renderer/core/page/focus_controller_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/dom/element.cc
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc
  • third_party/blink/renderer/core/page/focus_controller_test.cc
From 52027603880f62c80ff4c656a6d02d06b414ad84 Mon Sep 17 00:00:00 2001
From: Leo Lee <[email protected]>
Date: Fri, 17 Jul 2026 17:43:50 -0700
Subject: [PATCH] Preserve WasLastFocusFromUserGesture across page focus changes

Element::SetFocused() derives WasLastFocusFromUserGesture from the
incoming FocusType, treating every value other than kNone and kScript as
a user gesture. FocusType::kPage, used by FocusController when the
window loses or regains focus, was therefore overwriting the value
recorded by the previous element-level focus.

kPage re-applies focus to whichever element is already
Document::FocusedElement(); it does not represent a new choice of
focused element by either user or script. Skip the write in that case so
the flag continues to reflect how the element was originally focused.

This prevents a malicious page from laundering a script-focused element
into appearing user-gesture-focused by waiting for a tab switch, which
would bypass the spell-check user-dictionary-leak protection gate in
IdleSpellCheckController::RespondToChangedSelection.

Bug: 517398863
Change-Id: I07a5fc30b312d30244ea8d47d7f2cbfc65f5f42f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8096703
Reviewed-by: Jacques Newman <[email protected]>
Reviewed-by: Dan Clark <[email protected]>
Commit-Queue: Leo Lee <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1664264}
---

diff --git a/third_party/blink/renderer/core/dom/element.cc b/third_party/blink/renderer/core/dom/element.cc
index 1f28b5c..a7c2c17 100644
--- a/third_party/blink/renderer/core/dom/element.cc
+++ b/third_party/blink/renderer/core/dom/element.cc
@@ -8347,9 +8347,14 @@
 }
 
 void Element::SetFocused(bool now_focused, mojom::blink::FocusType focus_type) {
-  EnsureRareData().SetWasLastFocusFromUserGesture(
-      focus_type != mojom::blink::FocusType::kNone &&
-      focus_type != mojom::blink::FocusType::kScript);
+  // FocusType::kPage represents a page-level focus change (e.g. switching
+  // tabs) rather than focusing a different element, so preserve the existing
+  // value in that case.
+  if (focus_type != mojom::blink::FocusType::kPage) {
+    EnsureRareData().SetWasLastFocusFromUserGesture(
+        focus_type != mojom::blink::FocusType::kNone &&
+        focus_type != mojom::blink::FocusType::kScript);
+  }
   // Recurse up author shadow trees to mark shadow hosts if it matches :focus.
   // TODO(kochi): Handle UA shadows which marks multiple nodes as focused such
   // as <input type="date"> the same way as author shadow.
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 5379da5..bea53fa 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
@@ -323,7 +323,9 @@
     if (!IsUnrestricted() && ((mojom::blink::FocusType)focus_type ==
                                   mojom::blink::FocusType::kNone ||
                               (mojom::blink::FocusType)focus_type ==
-                                  mojom::blink::FocusType::kScript)) {
+                                  mojom::blink::FocusType::kScript ||
+                              (mojom::blink::FocusType)focus_type ==
+                                  mojom::blink::FocusType::kPage)) {
       ASSERT_EQ(State::kInactive, IdleChecker().GetState());
     } else {
       IdleChecker().SkipColdModeTimerForTesting();
diff --git a/third_party/blink/renderer/core/page/focus_controller_test.cc b/third_party/blink/renderer/core/page/focus_controller_test.cc
index a27d2584..71ae988 100644
--- a/third_party/blink/renderer/core/page/focus_controller_test.cc
+++ b/third_party/blink/renderer/core/page/focus_controller_test.cc
@@ -12,6 +12,7 @@
 #include "third_party/blink/renderer/core/css/properties/longhands.h"
 #include "third_party/blink/renderer/core/dom/column_pseudo_element.h"
 #include "third_party/blink/renderer/core/dom/element.h"
+#include "third_party/blink/renderer/core/dom/focus_params.h"
 #include "third_party/blink/renderer/core/dom/scroll_marker_group_pseudo_element.h"
 #include "third_party/blink/renderer/core/dom/scroll_marker_pseudo_element.h"
 #include "third_party/blink/renderer/core/dom/shadow_root.h"
@@ -387,6 +388,39 @@
             style->VisitedDependentColor(GetCSSPropertyColor()));
 }
 
+TEST_F(FocusControllerTest, PageFocusPreservesWasLastFocusFromUserGesture) {
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("<input id=target>");
+  Element* target = GetElementById("target");
+
+  GetFocusController().SetActive(true);
+  GetFocusController().SetFocused(true);
+
+  // Script-initiated focus.
+  target->Focus();
+  ASSERT_EQ(target, GetDocument().FocusedElement());
+  EXPECT_FALSE(target->WasLastFocusFromUserGesture());
+
+  // Page-level blur and re-focus (e.g., switching to another tab and back)
+  // should not change the per-element flag.
+  GetFocusController().SetFocused(false);
+  EXPECT_FALSE(target->WasLastFocusFromUserGesture());
+  GetFocusController().SetFocused(true);
+  ASSERT_EQ(target, GetDocument().FocusedElement());
+  EXPECT_FALSE(target->WasLastFocusFromUserGesture());
+
+  // User-initiated focus.
+  target->blur();
+  target->Focus(FocusParams(SelectionBehaviorOnFocus::kRestore,
+                            mojom::blink::FocusType::kMouse, nullptr));
+  ASSERT_EQ(target, GetDocument().FocusedElement());
+  EXPECT_TRUE(target->WasLastFocusFromUserGesture());
+
+  GetFocusController().SetFocused(false);
+  EXPECT_TRUE(target->WasLastFocusFromUserGesture());
+  GetFocusController().SetFocused(true);
+  EXPECT_TRUE(target->WasLastFocusFromUserGesture());
+}
+
 TEST_F(FocusControllerTest, FocusCanBeEmulated) {
   SetBodyInnerHTML("<div id=host></div>");
   auto& controller = GetFocusController();
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 5379da5..bea53fa 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
@@ -323,7 +323,9 @@
     if (!IsUnrestricted() && ((mojom::blink::FocusType)focus_type ==
                                   mojom::blink::FocusType::kNone ||
                               (mojom::blink::FocusType)focus_type ==
-                                  mojom::blink::FocusType::kScript)) {
+                                  mojom::blink::FocusType::kScript ||
+                              (mojom::blink::FocusType)focus_type ==
+                                  mojom::blink::FocusType::kPage)) {
       ASSERT_EQ(State::kInactive, IdleChecker().GetState());
     } else {
       IdleChecker().SkipColdModeTimerForTesting();
diff --git a/third_party/blink/renderer/core/page/focus_controller_test.cc b/third_party/blink/renderer/core/page/focus_controller_test.cc
index a27d2584..71ae988 100644
--- a/third_party/blink/renderer/core/page/focus_controller_test.cc
+++ b/third_party/blink/renderer/core/page/focus_controller_test.cc
@@ -12,6 +12,7 @@
 #include "third_party/blink/renderer/core/css/properties/longhands.h"
 #include "third_party/blink/renderer/core/dom/column_pseudo_element.h"
 #include "third_party/blink/renderer/core/dom/element.h"
+#include "third_party/blink/renderer/core/dom/focus_params.h"
 #include "third_party/blink/renderer/core/dom/scroll_marker_group_pseudo_element.h"
 #include "third_party/blink/renderer/core/dom/scroll_marker_pseudo_element.h"
 #include "third_party/blink/renderer/core/dom/shadow_root.h"
@@ -387,6 +388,39 @@
             style->VisitedDependentColor(GetCSSPropertyColor()));
 }
 
+TEST_F(FocusControllerTest, PageFocusPreservesWasLastFocusFromUserGesture) {
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("<input id=target>");
+  Element* target = GetElementById("target");
+
+  GetFocusController().SetActive(true);
+  GetFocusController().SetFocused(true);
+
+  // Script-initiated focus.
+  target->Focus();
+  ASSERT_EQ(target, GetDocument().FocusedElement());
+  EXPECT_FALSE(target->WasLastFocusFromUserGesture());
+
+  // Page-level blur and re-focus (e.g., switching to another tab and back)
+  // should not change the per-element flag.
+  GetFocusController().SetFocused(false);
+  EXPECT_FALSE(target->WasLastFocusFromUserGesture());
+  GetFocusController().SetFocused(true);
+  ASSERT_EQ(target, GetDocument().FocusedElement());
+  EXPECT_FALSE(target->WasLastFocusFromUserGesture());
+
+  // User-initiated focus.
+  target->blur();
+  target->Focus(FocusParams(SelectionBehaviorOnFocus::kRestore,
+                            mojom::blink::FocusType::kMouse, nullptr));
+  ASSERT_EQ(target, GetDocument().FocusedElement());
+  EXPECT_TRUE(target->WasLastFocusFromUserGesture());
+
+  GetFocusController().SetFocused(false);
+  EXPECT_TRUE(target->WasLastFocusFromUserGesture());
+  GetFocusController().SetFocused(true);
+  EXPECT_TRUE(target->WasLastFocusFromUserGesture());
+}
+
 TEST_F(FocusControllerTest, FocusCanBeEmulated) {
   SetBodyInnerHTML("<div id=host></div>");
   auto& controller = GetFocusController();
Loading diff…

Original Bug Report

reported by [email protected]

User-dictionary-leaks protection bypass via FocusType::kPage focus laundering

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: Blink’s focus handling contains a logic flaw where a script-focused element’s state can be laundered into a user-gesture-focused state. When a tab or window loses and regains focus, page focus events update the element with FocusType::kPage, which incorrectly overrides the gesture tracking flag. This potentially allows a malicious page to programmatically trigger spell-checking on arbitrary text and leak the user’s custom dictionary via side channels.

Affected files:

  • third_party/blink/renderer/core/dom/element.cc
  • third_party/blink/renderer/core/page/focus_controller.cc
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc

Estimated timestamp from git blame: 2025-10-23

Summary

There is a potential security bypass of the user-dictionary-leaks protection gate in Blink. When an element is focused programmatically by a script, WasLastFocusFromUserGesture is initially set to false. However, if the page loses and regains window focus, the page-level focus controller restores focus to the active element using FocusType::kPage. This transition incorrectly updates WasLastFocusFromUserGesture to true, laundering the script-focused state. An attacker can potentially exploit this to programmatically trigger spell-checking on arbitrary text ranges without direct user interaction, bypassing the protections designed to prevent custom dictionary leaks.

Root Cause Analysis

In third_party/blink/renderer/core/dom/element.cc (lines 8389-8392), Element::SetFocused determines if the last focus transition originated from a user gesture by excluding only kNone and kScript:

void Element::SetFocused(bool now_focused, mojom::blink::FocusType focus_type) {
  EnsureRareData().SetWasLastFocusFromUserGesture(
      focus_type != mojom::blink::FocusType::kNone &&
      focus_type != mojom::blink::FocusType::kScript);

However, mojom::blink::FocusType::kPage represents page focus restoration (e.g., when regaining window or tab focus). Under this logic, FocusType::kPage evaluates to true, which asserts WasLastFocusFromUserGesture = true on the focused element.

In third_party/blink/renderer/core/page/focus_controller.cc, when page-level focus is lost or gained, DispatchEventsOnWindowAndFocusedElement invokes SetFocused on the currently focused element using FocusType::kPage:

  • Unfocusing: focused_element->SetFocused(false, mojom::blink::FocusType::kPage); (line 1143)
  • Focusing: focused_element->SetFocused(true, mojom::blink::FocusType::kPage); (line 1157)

This sequence overrides the original script-focused (kScript) state and marks the element as gesture-focused, bypassing the safety gate in IdleSpellCheckController::RespondToChangedSelection (in third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller.cc lines 128-132).

Potential Attack Steps

(Note: These are potential steps as our tooling does not yet have the ability to execute code and verify them on a live system.)

  1. Serve an HTML page containing an editable element with candidate words:
    <div id="probe" contenteditable spellcheck="true">word1 word2 word3</div>
    
  2. Programmatically focus the element on load: probe.focus(). This sets WasLastFocusFromUserGesture to false.
  3. Wait for the user to switch tabs or minimize/restore the window. When the page loses and regains focus, page focus events update the element with FocusType::kPage, laundering WasLastFocusFromUserGesture to true.
  4. Programmatically move the selection caret over candidate words (e.g., using getSelection().collapse(...)).
  5. Because WasLastFocusFromUserGesture is now true, RespondToChangedSelection does not deactivate. It schedules hot-mode spell-checking, ultimately submitting the words to the spelling engine.
  6. Detect the spellcheck results via side channels (such as timing differences in processing or platform autocorrect indicators) to extract the private contents of the user’s custom dictionary.

Suggested Fix

Do not update the WasLastFocusFromUserGesture flag when the focus type is mojom::blink::FocusType::kPage. This preserves the element’s original programmatic or gesture-focused source during tab/window focus cycles. Modify Element::SetFocused in third_party/blink/renderer/core/dom/element.cc as follows:

void Element::SetFocused(bool now_focused, mojom::blink::FocusType focus_type) {
  if (focus_type != mojom::blink::FocusType::kPage) {
    EnsureRareData().SetWasLastFocusFromUserGesture(
        focus_type != mojom::blink::FocusType::kNone &&
        focus_type != mojom::blink::FocusType::kScript);
  }
  ...

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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