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 DOM
DescriptionIncorrect authorization in DOM
ComponentDOM
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.
Tracker517398863
Fix commit52027603880f (chromium/src) +45/-4
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-28 (per vendor advisory)
Fix landed2026-07-18 (public git)
Disclosed2026-08-25
Patch gap38 days — fix public in source before the advisory

Background

`WasLastFocusFromUserGesture`
a per-element flag in Blink, stored in element rare data, that records whether the element’s most recent focus came from a user action (mouse, keyboard and similar) rather than from script.
`mojom::blink::FocusType`
the enum describing why focus changed. kScript is programmatic focus(), kNone means no specific reason, input-driven values such as kMouse count as user gestures, and kPage is used by FocusController when the whole page or window gains or loses focus.
`IdleSpellCheckController::RespondToChangedSelection`
the Blink idle-time spell checker. According to the commit message, it uses this flag as a gate: it only starts checking for elements focused by a user gesture, to stop pages from learning the contents of the user’s custom spelling dictionary.
Page focus change
when a user switches tabs or windows and comes back, FocusController blurs and then refocuses the document’s existing Document::FocusedElement() with FocusType::kPage.

Root Cause Analysis

Element::SetFocused() overwrote WasLastFocusFromUserGesture on every call, treating every FocusType other than kNone and kScript as a user gesture. That included kPage, which FocusController uses only to reapply focus to the element that is already focused when the page or window loses and regains focus. The invariant “the flag reflects how this element was originally focused” was therefore broken. An element that script focused (flag false) became true as soon as the user switched away from the tab and back, even though the user never chose that element. Because IdleSpellCheckController::RespondToChangedSelection trusts this flag to decide whether spell checking is allowed, this “laundering” bypassed the gate that protects against leaking the user dictionary.

The fix skips the write completely when focus_type == kPage, keeping the existing value. The updated spell-check test now expects the idle checker to stay kInactive for kPage, just as for kNone and kScript.

Key insight
A page-level focus event that only reapplies existing focus was wrongly counted as a new user-gesture focus, so a non-gesture event could set a security-relevant “user gesture” bit. The fix treats kPage as neutral and leaves the flag unchanged.

Attack Path

  1. Script-focus an editable element A malicious page calls focus() on an <input> or another editable element containing chosen words. The element’s WasLastFocusFromUserGesture is false, so the spell-check gate stays closed.
  2. Wait for a tab or window switch The page waits, or persuades the user, to switch to another tab or window and back. This is ordinary user behaviour, not an interaction with the page’s content.
  3. Flag laundered On refocus, FocusController calls Element::SetFocused with FocusType::kPage. Before the patch this set WasLastFocusFromUserGesture to true for the script-focused element.
  4. Spell-check gate bypassed IdleSpellCheckController::RespondToChangedSelection now treats the element as user-focused and runs spell checking on content the attacker controls. That lets the page probe whether particular words are in the user’s custom dictionary through some observable effect of spell-check results (inferred; the observation channel is not shown in the patch).

Impact Assessment

Ordinary web content in the renderer could bypass a privacy gate meant to keep the user’s custom spell-check dictionary from leaking. Such a dictionary can contain names, addresses or other personal terms (inferred), and the leak would be through an observation channel the patch does not show (inferred). The attack needs spell checking to be enabled and the user to move focus away from the tab or window and back. No renderer compromise is needed. The advisory classifies this as “Incorrect authorization in DOM” and does not report in-the-wild exploitation.

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/page/focus_controller_test.cc
  • third_party/blink/renderer/core/dom/element.cc
  • third_party/blink/renderer/core/editing/spellcheck/idle_spell_check_controller_test.cc

Audit Directions

  • "User gesture" derived by exclusion
    Find other places that compute a gesture or trust bit as “not kScript/kNone” (or similar deny-lists over an enum). Newly added or system-originated enum values, such as kPage or focus forwarded from the browser, then silently count as user-initiated.
  • State re-derived on re-application events
    Audit handlers that reapply existing state (window refocus, back-forward cache restore, visibility changes, :focus-visible recomputation) for overwriting provenance flags that should only change when the user makes a new choice.
  • Consumers of `WasLastFocusFromUserGesture`
    Review other features gated on this flag, such as autofill, spell checking and focus-visible heuristics, for the same laundering by tab switch, and check whether they should also require a current user activation.
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