Medium watch CVSS 6.5 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in DOM
DescriptionObservable discrepancy in DOM
ComponentDOM
Sub-componentAutofill
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker540015493
Fix commitb12935e03a81 (chromium/src) +429/-58
CWECWE-203
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-07-28 (per vendor advisory)
Fix landed2026-08-10 (public git)
Disclosed2026-09-08
Patch gapNot computed: several commits cite this bug and it is not yet established which one is the fix.

Background

Autofill preview state
the transient state (HTMLFormControlElement::IsPreviewed(), set via SetSuggestedValue()) in which a form control visually shows an autofill-suggested value the user has not yet accepted, and which script must not be able to read.
MenuList vs ListBox `<select>`
a <select> renders as a collapsed button-and-popup “menulist” (UsesMenuList() true) or, with size set, as an always-open scrollable “listbox”; the two are backed by MenuListSelectType and ListBoxSelectType.
Top-layer popover overlay
autofill_popover_ (SelectAutofillPreviewElement) plus autofill_popover_text_, a shadow-DOM popover=manual element that paints the previewed label on top of the control without disturbing the control’s own DOM or scroll offset.
Scroll-disclosure masking
legacy mitigations (crbug.com/1261689) that hid the effects of scrolling a previewed listbox to the suggested option by forcing the scroll-offset getter to return 0 and by freezing the selected scroll marker (selected_marker_).

Root Cause Analysis

Before this change, only appearance:base-select menulists used the isolated autofill_popover_ overlay; a standard menulist revealed the suggestion by mutating its inner text via OptionToBeShown() returning select_->suggested_option_, and a listbox (ListBoxSelectType::GetAutofillPreviewElement() returned nullptr) revealed it by physically scrolling the real listbox to bring the previewed option into view. Because the actual scroll happened, the “suggested value is not observable” invariant was upheld only by a growing set of point mitigations that masked individual observation channels: the scroll-offset getter in element.cc returned 0 for previewed listboxes, and scroll_marker_group_data.cc returned the frozen selected_marker_ so the CSS scroll marker would not follow the preview scroll. Any DOM-observable channel not covered by a mask (here, the scroll-marker group, plus scroll geometry generally) let page script infer which option was previewed and thus recover the autofill-suggested value without user consent — an observable discrepancy between what was masked and what actually moved.

The fix introduces the default-on SelectAutofillPopoverPreview feature and routes all <select> previews (both menulist and listbox) through the top-layer autofill_popover_ overlay via the shared SelectType::DidSetSuggestedOption(), so the underlying control is never scrolled and its text is never mutated. With the root cause (the real scroll/DOM mutation) gone, the now-redundant masking hacks are correctly disabled behind the feature check, and OptionToBeShown() no longer returns the suggested option for menulists.

Key insight
The bug was mitigating a self-inflicted leak — actually scrolling/mutating the live control to preview an unaccepted value and then trying to mask every API that could observe it — which inevitably left uncovered channels (the scroll marker group). The fix removes the leak’s source by painting the preview in an isolated top-layer popover so the real control never moves, making the masks unnecessary.

Attack Path

  1. Render a scrollable listbox The attacker page places a <select size=N> (a ListBoxSelectType) whose option list is tall enough to scroll and whose selectable options encode/depend on autofillable data (e.g., a value list).
  2. Trigger an autofill preview When the user hovers an autofill suggestion, the browser calls SetSuggestedValue(), entering IsPreviewed() state; pre-patch, ListBoxSelectType scrolls the real listbox to bring the suggested (unaccepted) option into view.
  3. Observe an unmasked channel Page script reads a scroll-derived signal that the legacy masks miss — chiefly the selected scroll marker exposed through the scroll-marker group (selected_marker_), rather than the masked scrollTop, which reveals which option was scrolled to.
  4. Recover the suggested value From the observed marker/scroll target the script infers the previewed option’s identity, disclosing the autofill-suggested value before the user accepts it. (Exploitation is confined to reading DOM-observable state in the renderer; no memory-safety primitive is involved — (inferred) that the leaked datum is autofill PII.)

Impact Assessment

An attacker-controlled page can observe an autofill-previewed (not yet accepted) <select> value through a DOM/scroll side channel that the legacy masking did not cover, disclosing autofilled data such as user-profile selections within the renderer process. The precondition is that the victim triggers an autofill preview over a page-controlled listbox <select> while attacker script observes the scroll-marker/scroll state. This is an information-disclosure (“Observable discrepancy in DOM”) issue; the advisory does not report memory corruption or any escalation beyond information disclosure, and none is (inferred) here.

Changed Functions

FunctionChangeNotes
GetAutofillPreviewElement
third_party/blink/renderer/core/html/forms/select_type.cc
modified
if
third_party/blink/renderer/core/html/forms/select_type.cc
modified
TEST_F
third_party/blink/renderer/core/html/forms/html_select_element_test.cc
modified

Files Changed

  • third_party/blink/renderer/platform/runtime_enabled_features.json5
  • third_party/blink/renderer/core/html/forms/select_type.cc
  • third_party/blink/web_tests/fast/forms/suggested-value-after-empty-suggested-value-expected.txt
  • third_party/blink/web_tests/fast/forms/select/select-autofill-popover-preview.html
  • third_party/blink/renderer/core/html/resources/html.css
  • third_party/blink/renderer/core/dom/scroll_marker_group_data.cc
  • third_party/blink/renderer/core/html/forms/select_type.h
  • third_party/blink/web_tests/fast/forms/select/select-autofill-scroll-oracle.html
  • third_party/blink/renderer/core/html/forms/html_select_element_test.cc
  • third_party/blink/renderer/core/dom/element.cc

Audit Directions

  • Mask-the-symptom mitigations
    Flag any place that lets a privileged/unaccepted value affect real layout, scroll, or DOM state and then tries to hide it by overriding specific getters (return 0, frozen accessors); enumerate every observation channel, since new APIs (scroll markers, IntersectionObserver, scrollHeight) reopen the leak.
  • Preview / suggested-value state
    Audit all IsPreviewed() / suggested_option_ consumers (OptionToBeShown, selection, ARIA, accessibility tree) for paths that surface the suggested value into any script-observable property before acceptance.
  • Feature-flag-gated security behavior
    Where a mitigation is disabled under a new default-on flag, verify the replacement path truly removes the root cause on the disabled branch, and confirm the fallback (feature off) still masks correctly, as the added DisabledFallback tests do.
From b12935e03a81ffbfcbb68a792dc9d51ab6b968ea Mon Sep 17 00:00:00 2001
From: Jochen Eisinger <jochen@chromium.org>
Date: Mon, 10 Aug 2026 03:52:21 -0700
Subject: [PATCH] [autofill] Implement unified popover preview for <select> controls

Standardize <select> form controls (both MenuListSelectType and
ListBoxSelectType) to render Autofill previews using a top-layer popover
overlay (autofill_popover_ and autofill_popover_text_).

In MenuListSelectType, remove the appearance:base-select condition so
that any suggested option triggers the popover overlay, and update
OptionToBeShown() to never return suggested options for standard
menulists.

In ListBoxSelectType, instantiate autofill_popover_ and
autofill_popover_text_ during shadow DOM initialization and implement
GetAutofillPreviewElement() to return the popover instance.

Update html.css to apply full-control anchor sizing, start text
alignment with inline padding, and enforce -webkit-small-control font
pinning on the popover overlay.

TAG=agy
CONV=4259a896-ab67-4bff-bd6c-afa15ec4ad96

Bug: 40060525, 40057398, 518032534, 540015493, 517219513, 540021850
Change-Id: Ic0e2d79fd518479afeb36bf32870f889d32591f1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8175874
Commit-Queue: Jochen Eisinger <jochen@chromium.org>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Reviewed-by: Christoph Schwering <schwering@google.com>
Cr-Commit-Position: refs/heads/main@{#1676341}
---
diff --git a/third_party/blink/renderer/core/dom/element.cc b/third_party/blink/renderer/core/dom/element.cc
index 938dd8e7..cdfe1c4 100644
--- a/third_party/blink/renderer/core/dom/element.cc
+++ b/third_party/blink/renderer/core/dom/element.cc
@@ -2679,10 +2679,12 @@
   }
 
   // Don't disclose scroll position in preview state. See crbug.com/1261689.
-  auto* select_element = DynamicTo<HTMLSelectElement>(this);
-  if (select_element && !select_element->UsesMenuList() &&
-      select_element->IsPreviewed()) {
-    return 0;
+  if (!RuntimeEnabledFeatures::SelectAutofillPopoverPreviewEnabled()) {
+    auto* select_element = DynamicTo<HTMLSelectElement>(this);
+    if (select_element && !select_element->UsesMenuList() &&
+        select_element->IsPreviewed()) {
+      return 0;
+    }
   }
 
   LayoutBox* box = GetLayoutBoxForScrolling();
diff --git a/third_party/blink/renderer/core/dom/scroll_marker_group_data.cc b/third_party/blink/renderer/core/dom/scroll_marker_group_data.cc
index feea272..3678350 100644
--- a/third_party/blink/renderer/core/dom/scroll_marker_group_data.cc
+++ b/third_party/blink/renderer/core/dom/scroll_marker_group_data.cc
@@ -14,6 +14,7 @@
 #include "third_party/blink/renderer/core/layout/layout_box.h"
 #include "third_party/blink/renderer/core/paint/paint_layer_scrollable_area.h"
 #include "third_party/blink/renderer/core/scroll/scroll_into_view_util.h"
+#include "third_party/blink/renderer/platform/runtime_enabled_features.h"
 #include "third_party/blink/renderer/platform/wtf/wtf_size_t.h"
 
 namespace blink {
@@ -590,9 +591,11 @@
     // Form controls in autofill preview state may have been scrolled to bring
     // the previewed value into view. Keep the current selection so that the
     // suggested value cannot be observed via the selected scroll marker.
-    if (auto* form_control = DynamicTo<HTMLFormControlElement>(scroller);
-        form_control && form_control->IsPreviewed()) {
-      return selected_marker_;
+    if (!RuntimeEnabledFeatures::SelectAutofillPopoverPreviewEnabled()) {
+      if (auto* form_control = DynamicTo<HTMLFormControlElement>(scroller);
+          form_control && form_control->IsPreviewed()) {
+        return selected_marker_;
+      }
     }
     LayoutBox* scroller_box = scroller->GetLayoutBox();
     DCHECK(scroller_box);
diff --git a/third_party/blink/renderer/core/html/forms/select_type.cc b/third_party/blink/renderer/core/html/forms/select_type.cc
index 05d7f972..5886114 100644
--- a/third_party/blink/renderer/core/html/forms/select_type.cc
+++ b/third_party/blink/renderer/core/html/forms/select_type.cc
@@ -294,8 +294,6 @@
   bool IsAppearanceBasePicker() const override;
   bool PickerIsPopover() const override;
   void SetIsAppearanceBasePickerForDisplayNone(bool) override;
-  HTMLSelectElement::SelectAutofillPreviewElement* GetAutofillPreviewElement()
-      const override;
   Element& InnerElement() const override;
   void ShowPopup(PopupMenu::ShowEventType type) override;
   void HidePopup(SelectPopupHideBehavior) override;
@@ -325,8 +323,6 @@
   Member<const ComputedStyle> option_style_;
   Member<HTMLSlotElement> button_slot_;
   Member<PopoverElementForAppearanceBase> popover_;
-  Member<HTMLSelectElement::SelectAutofillPreviewElement> autofill_popover_;
-  Member<HTMLDivElement> autofill_popover_text_;
   Member<HTMLSlotElement> popover_input_slot_;
   Member<HTMLSlotElement> popover_options_slot_;
   // TODO(40146374): The option_slot_ can be removed when the CustomizableSelect
@@ -354,8 +350,6 @@
   visitor->Trace(option_style_);
   visitor->Trace(button_slot_);
   visitor->Trace(popover_);
-  visitor->Trace(autofill_popover_);
-  visitor->Trace(autofill_popover_text_);
   visitor->Trace(popover_input_slot_);
   visitor->Trace(popover_options_slot_);
   visitor->Trace(option_slot_);
@@ -620,18 +614,9 @@
   popover_options_slot_ = MakeGarbageCollected<HTMLSlotElement>(doc);
   popover_options_slot_->SetIdAttribute(shadow_element_names::kSelectOptions);
 
-  autofill_popover_ =
-      MakeGarbageCollected<HTMLSelectElement::SelectAutofillPreviewElement>(
-          doc, select_);
-  autofill_popover_->SetShadowPseudoId(
-      shadow_element_names::kSelectAutofillPreview);
-  root.appendChild(autofill_popover_);
-  autofill_popover_->setAttribute(html_names::kPopoverAttr, keywords::kManual);
-
-  autofill_popover_text_ = MakeGarbageCollected<HTMLDivElement>(doc);
-  autofill_popover_text_->SetShadowPseudoId(
-      shadow_element_names::kSelectAutofillPreviewText);
-  autofill_popover_->appendChild(autofill_popover_text_);
+  if (!RuntimeEnabledFeatures::SelectAutofillPopoverPreviewEnabled()) {
+    CreateAutofillPopover(root);
+  }
 
   if (RuntimeEnabledFeatures::FilterableSelectEnabled() &&
       select_->NumDescendantInputs()) {
@@ -662,6 +647,10 @@
     popover_input_slot_ = nullptr;
     popover_->AppendChild(popover_options_slot_);
   }
+
+  if (RuntimeEnabledFeatures::SelectAutofillPopoverPreviewEnabled()) {
+    CreateAutofillPopover(root);
+  }
 }
 
 void MenuListSelectType::ManuallyAssignSlots() {
@@ -779,11 +768,6 @@
   is_appearance_base_picker_for_display_none_ = value;
 }
 
-HTMLSelectElement::SelectAutofillPreviewElement*
-MenuListSelectType::GetAutofillPreviewElement() const {
-  return autofill_popover_;
-}
-
 Element& MenuListSelectType::InnerElement() const {
   return *inner_element_;
 }
@@ -1001,17 +985,9 @@
   if (native_popup_is_visible_) {
     popup_->UpdateFromElement(PopupMenu::kBySelectionChange);
   }
-  if (select_->IsAppearanceBase()) {
-    if (option) {
-      autofill_popover_->ShowPopoverInternal(select_, &ASSERT_NO_EXCEPTION);
-      autofill_popover_text_->setInnerText(option->label());
-    } else {
-      autofill_popover_text_->setInnerText(g_empty_string);
-      autofill_popover_->HidePopoverInternal(
-          /*invoker=*/nullptr, HidePopoverFocusBehavior::kNone,
-          HidePopoverTransitionBehavior::kNoEventsNoWaiting,
-          /*exception_state=*/nullptr);
-    }
+  if (RuntimeEnabledFeatures::SelectAutofillPopoverPreviewEnabled() ||
+      select_->IsAppearanceBase()) {
+    SelectType::DidSetSuggestedOption(option);
   }
 }
 
@@ -1175,7 +1151,8 @@
     return option;
   // In appearance:base-select mode, we don't want to reveal the suggested
   // option anywhere except in autofill_popover_.
-  if (select_->suggested_option_ && !select_->IsAppearanceBase()) {
+  if (!RuntimeEnabledFeatures::SelectAutofillPopoverPreviewEnabled() &&
+      select_->suggested_option_ && !select_->IsAppearanceBase()) {
     return select_->suggested_option_.Get();
   }
   // TODO(tkent): We should not call OptionToBeShown() in IsMultiple() case.
@@ -1303,8 +1280,6 @@
   bool IsAppearanceBasePicker() const override;
   bool PickerIsPopover() const override;
   void SetIsAppearanceBasePickerForDisplayNone(bool) override;
-  HTMLSelectElement::SelectAutofillPreviewElement* GetAutofillPreviewElement()
-      const override;
   void DidRecalcStyle(const StyleRecalcChange) override;
 
  private:
@@ -1664,6 +1639,10 @@
 void ListBoxSelectType::DidSetSuggestedOption(HTMLOptionElement* option) {
   if (!select_->GetLayoutObject())
     return;
+  if (RuntimeEnabledFeatures::SelectAutofillPopoverPreviewEnabled()) {
+    SelectType::DidSetSuggestedOption(option);
+    return;
+  }
   // When ending preview state, don't leave the scroll position at the
   // previewed element but return to the active selection end if it is
   // defined or to the first selectable option. See crbug.com/1261689.
@@ -1996,6 +1975,8 @@
     root.appendChild(option_slot_);
     listbox_ = nullptr;
   }
+
+  CreateAutofillPopover(root);
 }
 
 void ListBoxSelectType::ManuallyAssignSlots() {
@@ -2052,12 +2033,6 @@
                   "UsesMenuList(). ListBoxSelectType does not have a picker.";
 }
 
-HTMLSelectElement::SelectAutofillPreviewElement*
-ListBoxSelectType::GetAutofillPreviewElement() const {
-  // TODO(crbug.com/357649033): Implement this
-  return nullptr;
-}
-
 void ListBoxSelectType::DidRecalcStyle(const StyleRecalcChange) {
   UpdateOptionMutationObservers(/*in_style_recalc=*/true);
 }
@@ -2108,6 +2083,50 @@
 
 void SelectType::Trace(Visitor* visitor) const {
   visitor->Trace(select_);
+  visitor->Trace(autofill_popover_);
+  visitor->Trace(autofill_popover_text_);
+}
+
+void SelectType::CreateAutofillPopover(ShadowRoot& root) {
+  if (!RuntimeEnabledFeatures::SelectAutofillPopoverPreviewEnabled() &&
+      !select_->IsAppearanceBase()) {
+    return;
+  }
+  select_->SetMayBeImplicitAnchor();
+  Document& doc = select_->GetDocument();
+  autofill_popover_ =
+      MakeGarbageCollected<HTMLSelectElement::SelectAutofillPreviewElement>(
+          doc, select_);
+  autofill_popover_->SetShadowPseudoId(
+      shadow_element_names::kSelectAutofillPreview);
+  root.appendChild(autofill_popover_);
+  autofill_popover_->setAttribute(html_names::kPopoverAttr, keywords::kManual);
+
+  autofill_popover_text_ = MakeGarbageCollected<HTMLDivElement>(doc);
+  autofill_popover_text_->SetShadowPseudoId(
+      shadow_element_names::kSelectAutofillPreviewText);
+  autofill_popover_->appendChild(autofill_popover_text_);
+}
+
+HTMLSelectElement::SelectAutofillPreviewElement*
+SelectType::GetAutofillPreviewElement() const {
+  return autofill_popover_;
+}
+
+void SelectType::DidSetSuggestedOption(HTMLOptionElement* option) {
+  if (!autofill_popover_) {
+    return;
+  }
+  if (option) {
+    autofill_popover_->ShowPopoverInternal(select_, &ASSERT_NO_EXCEPTION);
+    autofill_popover_text_->setInnerText(option->label());
+  } else {
+    autofill_popover_text_->setInnerText(g_empty_string);
+    autofill_popover_->HidePopoverInternal(
+        /*invoker=*/nullptr, HidePopoverFocusBehavior::kNone,
+        HidePopoverTransitionBehavior::kNoEventsNoWaiting,
+        /*exception_state=*/nullptr);
+  }
 }
 
 void SelectType::OptionRemoved(HTMLOptionElement& option) {}
diff --git a/third_party/blink/renderer/core/html/forms/select_type.h b/third_party/blink/renderer/core/html/forms/select_type.h
index aba5b06..3c04fd057 100644
--- a/third_party/blink/renderer/core/html/forms/select_type.h
+++ b/third_party/blink/renderer/core/html/forms/select_type.h
@@ -35,7 +35,7 @@
   virtual void DidBlur() = 0;
   virtual void DidDetachLayoutTree();
   virtual void DidRecalcStyle(const StyleRecalcChange change);
-  virtual void DidSetSuggestedOption(HTMLOptionElement* option) = 0;
+  virtual void DidSetSuggestedOption(HTMLOptionElement* option);
   virtual void SaveLastSelection() = 0;
 
   // Update style of text in the CSS box on style or selected OPTION change.
@@ -72,7 +72,7 @@
   virtual bool PickerIsPopover() const = 0;
   virtual void SetIsAppearanceBasePickerForDisplayNone(bool) = 0;
   virtual HTMLSelectElement::SelectAutofillPreviewElement*
-  GetAutofillPreviewElement() const = 0;
+  GetAutofillPreviewElement() const;
   virtual Element& InnerElement() const;
   virtual void ShowPopup(PopupMenu::ShowEventType type);
   virtual void HidePopup(SelectPopupHideBehavior);
@@ -95,7 +95,11 @@
                                      SkipDirection direction,
                                      int skip) const;
 
+  void CreateAutofillPopover(ShadowRoot& root);
+
   const Member<HTMLSelectElement> select_;
+  Member<HTMLSelectElement::SelectAutofillPreviewElement> autofill_popover_;
+  Member<HTMLDivElement> autofill_popover_text_;
   bool will_be_destroyed_ = false;
 
  private:
diff --git a/third_party/blink/renderer/core/html/resources/html.css b/third_party/blink/renderer/core/html/resources/html.css
index 789de46..9bd97f6e 100644
--- a/third_party/blink/renderer/core/html/resources/html.css
+++ b/third_party/blink/renderer/core/html/resources/html.css
@@ -2355,7 +2355,8 @@
 select::-internal-select-autofill-preview-text {
   overflow: hidden;
   text-overflow: ellipsis;
-  text-align: center;
+  text-align: start;
+  padding-inline: -internal-auto-base(2px, 0.5em);
   white-space: nowrap;
   font: -webkit-small-control !important;
   /* See input::-internal-input-suggested: pin a generic family so an author
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index fa184ab7..9a56eac8 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -5473,6 +5473,10 @@
       },
     },
     {
+      name: "SelectAutofillPopoverPreview",
+      status: "stable",
+    },
+    {
       // The selectedcontentelement attribute is for connecting select elements
       // to arbitrary <selectedcontent> elements.
       // https://github.com/openui/open-ui/issues/1063
diff --git a/third_party/blink/renderer/core/html/forms/html_select_element_test.cc b/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
index b2c7f16..67dbac7 100644
--- a/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
+++ b/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
@@ -110,6 +110,36 @@
   EXPECT_EQ(select->UserHasEditedTheField(), true);
 }
 
+TEST_F(HTMLSelectElementTest, MenuListAutofillPreviewDisabledFallback) {
+  ScopedSelectAutofillPopoverPreviewForTest disable_popover_preview(false);
+  SetHtmlInnerHTML(
+      "<!DOCTYPE HTML><select id='sel'>"
+      "<option value='111' selected>111</option>"
+      "<option value='222'>222</option></select>");
+  auto* select = To<HTMLSelectElement>(GetElementById("sel"));
+
+  // MenuList always supports implicit anchor for the ::picker popover.
+  EXPECT_TRUE(select->MayBeImplicitAnchor());
+
+  // When SelectAutofillPopoverPreview is disabled, the shadow DOM popover
+  // preview element is omitted.
+  EXPECT_EQ(nullptr, select->GetAutofillPreviewElement());
+  EXPECT_EQ("111", select->InnerElement().textContent());
+
+  // Setting the suggested value mutates the menulist inner text node directly
+  // via OptionToBeShown().
+  select->SetSuggestedValue("222");
+  ASSERT_TRUE(select->IsPreviewed());
+  EXPECT_EQ("222", select->InnerElement().textContent());
+  EXPECT_EQ("111", select->SelectedOption()->value());
+  EXPECT_EQ(nullptr, select->GetAutofillPreviewElement());
+
+  // Clearing the preview restores the original selection's inner text.
+  select->SetSuggestedValue("");
+  ASSERT_FALSE(select->IsPreviewed());
+  EXPECT_EQ("111", select->InnerElement().textContent());
+}
+
 TEST_F(HTMLSelectElementTest, ListBoxSuggestedOptionScrollTargetGroup) {
   StringBuilder html;
   html.Append(
@@ -135,9 +165,8 @@
   ASSERT_TRUE(group);
   ASSERT_EQ(group->Selected(), first_anchor);
 
-  // Setting the suggested option scrolls the listbox to bring it into view,
-  // but the selected scroll marker should not follow that scroll while the
-  // suggestion has not been accepted.
+  // Setting the suggested option displays a popover preview overlay without
+  // scrolling the listbox, so the selected scroll marker is unchanged.
   select->SetSuggestedValue("v15");
   ASSERT_TRUE(select->IsPreviewed());
   test::RunPendingTasks();
@@ -145,7 +174,7 @@
   EXPECT_EQ(group->Selected(), first_anchor);
 
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/html/forms/html_select_element_test.cc b/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
index b2c7f16..67dbac7 100644
--- a/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
+++ b/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
@@ -110,6 +110,36 @@
   EXPECT_EQ(select->UserHasEditedTheField(), true);
 }
 
+TEST_F(HTMLSelectElementTest, MenuListAutofillPreviewDisabledFallback) {
+  ScopedSelectAutofillPopoverPreviewForTest disable_popover_preview(false);
+  SetHtmlInnerHTML(
+      "<!DOCTYPE HTML><select id='sel'>"
+      "<option value='111' selected>111</option>"
+      "<option value='222'>222</option></select>");
+  auto* select = To<HTMLSelectElement>(GetElementById("sel"));
+
+  // MenuList always supports implicit anchor for the ::picker popover.
+  EXPECT_TRUE(select->MayBeImplicitAnchor());
+
+  // When SelectAutofillPopoverPreview is disabled, the shadow DOM popover
+  // preview element is omitted.
+  EXPECT_EQ(nullptr, select->GetAutofillPreviewElement());
+  EXPECT_EQ("111", select->InnerElement().textContent());
+
+  // Setting the suggested value mutates the menulist inner text node directly
+  // via OptionToBeShown().
+  select->SetSuggestedValue("222");
+  ASSERT_TRUE(select->IsPreviewed());
+  EXPECT_EQ("222", select->InnerElement().textContent());
+  EXPECT_EQ("111", select->SelectedOption()->value());
+  EXPECT_EQ(nullptr, select->GetAutofillPreviewElement());
+
+  // Clearing the preview restores the original selection's inner text.
+  select->SetSuggestedValue("");
+  ASSERT_FALSE(select->IsPreviewed());
+  EXPECT_EQ("111", select->InnerElement().textContent());
+}
+
 TEST_F(HTMLSelectElementTest, ListBoxSuggestedOptionScrollTargetGroup) {
   StringBuilder html;
   html.Append(
@@ -135,9 +165,8 @@
   ASSERT_TRUE(group);
   ASSERT_EQ(group->Selected(), first_anchor);
 
-  // Setting the suggested option scrolls the listbox to bring it into view,
-  // but the selected scroll marker should not follow that scroll while the
-  // suggestion has not been accepted.
+  // Setting the suggested option displays a popover preview overlay without
+  // scrolling the listbox, so the selected scroll marker is unchanged.
   select->SetSuggestedValue("v15");
   ASSERT_TRUE(select->IsPreviewed());
   test::RunPendingTasks();
@@ -145,7 +174,7 @@
   EXPECT_EQ(group->Selected(), first_anchor);
 
   // Once the suggestion is cleared and a value is committed, the selected
-  // scroll marker tracks the listbox scroll position again.
+  // option is scrolled into view and the scroll marker tracks it.
   select->setValueForBinding("v15");
   ASSERT_FALSE(select->IsPreviewed());
   test::RunPendingTasks();
@@ -153,6 +182,98 @@
   EXPECT_NE(group->Selected(), first_anchor);
 }
 
+TEST_F(HTMLSelectElementTest,
+       ListBoxAutofillPreviewDoesNotScrollOrResetScroll) {
+  StringBuilder html;
+  html.Append("<!DOCTYPE HTML><select id='sel' size='4'>");
+  for (int i = 0; i < 20; ++i) {
+    html.AppendFormat("<option id='o%d' value='v%d'>option %d</option>", i, i,
+                      i);
+  }
+  html.Append("</select>");
+  SetHtmlInnerHTML(html.ToString().Utf8());
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+
+  auto* select = To<HTMLSelectElement>(GetElementById("sel"));
+
+  // When enabled, listbox supports implicit anchor for autofill popover.
+  EXPECT_TRUE(select->MayBeImplicitAnchor());
+
+  // 1. Initial preview does not scroll the listbox.
+  EXPECT_EQ(0.0, select->scrollTop());
+  select->SetSuggestedValue("v15");
+  ASSERT_TRUE(select->IsPreviewed());
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+  EXPECT_EQ(0.0, select->scrollTop());
+
+  // 2. Clear preview.
+  select->SetSuggestedValue("");
+  ASSERT_FALSE(select->IsPreviewed());
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+  EXPECT_EQ(0.0, select->scrollTop());
+
+  // 3. User scrolls the listbox.
+  select->setScrollTop(50);
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+  double scrolled_top = select->scrollTop();
+  EXPECT_GT(scrolled_top, 0.0);
+
+  // 4. Setting a suggested value does not alter the listbox scroll position or
+  // mask scrollTop().
+  select->SetSuggestedValue("v15");
+  ASSERT_TRUE(select->IsPreviewed());
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+  EXPECT_EQ(scrolled_top, select->scrollTop());
+
+  // 5. Clearing the suggested value preserves the user's scroll position.
+  select->SetSuggestedValue("");
+  ASSERT_FALSE(select->IsPreviewed());
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+  EXPECT_EQ(scrolled_top, select->scrollTop());
+}
+
+TEST_F(HTMLSelectElementTest, ListBoxAutofillPreviewDisabledFallback) {
+  ScopedSelectAutofillPopoverPreviewForTest disable_popover_preview(false);
+  StringBuilder html;
+  html.Append("<!DOCTYPE HTML><select id='sel' size='4'>");
+  for (int i = 0; i < 20; ++i) {
+    html.AppendFormat("<option id='o%d' value='v%d'>option %d</option>", i, i,
+                      i);
+  }
+  html.Append("</select>");
+  SetHtmlInnerHTML(html.ToString().Utf8());
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+
+  auto* select = To<HTMLSelectElement>(GetElementById("sel"));
+
+  // Popover preview element is omitted when feature is disabled.
+  EXPECT_EQ(nullptr, select->GetAutofillPreviewElement());
+  EXPECT_EQ(0.0, select->scrollTop());
+
+  // Setting the suggested value scrolls the listbox to the previewed option,
+  // but scrollTop() is masked to 0.0 to prevent scroll disclosure.
+  select->SetSuggestedValue("v15");
+  ASSERT_TRUE(select->IsPreviewed());
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+  EXPECT_EQ(0.0, select->scrollTop());
+
+  // Clearing the preview resets the scroll position to the first selectable
+  // option.
+  select->SetSuggestedValue("");
+  ASSERT_FALSE(select->IsPreviewed());
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+  EXPECT_EQ(0.0, select->scrollTop());
+}
+
 TEST_F(HTMLSelectElementTest, SaveRestoreSelectSingleFormControlState) {
   SetHtmlInnerHTML(
       "<!DOCTYPE HTML><select id='sel'>"
diff --git a/third_party/blink/web_tests/fast/forms/select/select-autofill-popover-preview.html b/third_party/blink/web_tests/fast/forms/select/select-autofill-popover-preview.html
new file mode 100644
index 0000000..6fdbab4
--- /dev/null
+++ b/third_party/blink/web_tests/fast/forms/select/select-autofill-popover-preview.html
@@ -0,0 +1,113 @@
+<!DOCTYPE html>
+<link rel=author href="mailto:jochen@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/40060525">
+<link rel=help href="https://issues.chromium.org/issues/40057398">
+<script src="../../../resources/testharness.js"></script>
+<script src="../../../resources/testharnessreport.js"></script>
+<script src="../resources/common.js"></script>
+<style>
+  @font-face {
+    font-family: Ahem;
+    src: url(../../../resources/Ahem.ttf);
+  }
+  .use-ahem {
+    font-family: Ahem;
+    font-size: 40px;
+    font-feature-settings: "case", "ss01";
+    width: 200px;
+    height: 100px;
+  }
+</style>
+
+<select id="menulist" class="use-ahem">
+  <option value="v1">Value 1</option>
+  <option value="v2">Value 2</option>
+</select>
+
+<select id="listbox" class="use-ahem" size="4">
+  <option value="v1">Value 1</option>
+  <option value="v2">Value 2</option>
+</select>
+
+<select id="listbox-scrollable" size="4">
+  <option value="v1">Value 1</option>
+  <option value="v2">Value 2</option>
+  <option value="v3">Value 3</option>
+  <option value="v4">Value 4</option>
+  <option value="v5">Value 5</option>
+  <option value="v6">Value 6</option>
+  <option value="v7">Value 7</option>
+  <option value="v8">Value 8</option>
+</select>
+
+<script>
+function testAutofillPopoverPreview(id, description) {
+  promise_test(async () => {
+    await document.fonts.ready;
+    const select = document.getElementById(id);
+    assert_true(!!select, `Element ${id} should exist`);
+
+    const initialStyle = window.getComputedStyle(select);
+    assert_equals(initialStyle.fontFamily, 'Ahem', `${id} should use author font initially`);
+
+    internals.setSuggestedValue(select, 'v2');
+
+    const shadowRoot = internals.shadowRoot(select);
+    const popover = getElementByPseudoId(shadowRoot, '-internal-select-autofill-preview');
+    assert_true(!!popover, `${id} autofill_popover_ element should exist in shadow DOM`);
+    assert_true(popover.matches(':popover-open'), `${id} autofill_popover_ should be open in preview state`);
+    assert_equals(window.getComputedStyle(popover).display, 'grid', `${id} autofill_popover_ display should be grid when open`);
+
+    // Full-element anchor sizing dimensions
+    assert_equals(window.getComputedStyle(popover).positionArea, 'center', `${id} autofill_popover_ position-area should be center`);
+    assert_equals(popover.offsetWidth, select.offsetWidth, `${id} autofill_popover_ width should match anchor select width`);
+    assert_equals(popover.offsetHeight, select.offsetHeight, `${id} autofill_popover_ height should match anchor select height`);
+
+    // Text-align start and text content
+    const popoverText = getElementByPseudoId(shadowRoot, '-internal-select-autofill-preview-text');
+    assert_true(!!popoverText, `${id} autofill_popover_text_ should exist in shadow DOM`);
+    assert_equals(popoverText.textContent, 'Value 2', `${id} autofill_popover_text_ should contain the label of the previewed option`);
+    const textStyle = window.getComputedStyle(popoverText);
+    assert_equals(textStyle.textAlign, 'start', `${id} autofill_popover_text_ should have text-align: start`);
+
+    // -webkit-small-control font pinning
+    assert_not_equals(textStyle.fontFamily, 'Ahem', `${id} autofill_popover_text_ should not use author font family in preview state`);
+    assert_not_equals(textStyle.fontSize, '40px', `${id} autofill_popover_text_ should pin font size to -webkit-small-control`);
+    assert_equals(textStyle.fontFeatureSettings, 'normal', `${id} autofill_popover_text_ should pin font-feature-settings to normal`);
+
+    // End preview
+    internals.setSuggestedValue(select, '');
+    assert_false(popover.matches(':popover-open'), `${id} autofill_popover_ should close when preview ends`);
+    assert_equals(window.getComputedStyle(popover).display, 'none', `${id} autofill_popover_ display should be none when closed`);
+  }, description);
+}
+
+testAutofillPopoverPreview('menulist', 'Autofill popover preview on menulist <select>');
+testAutofillPopoverPreview('listbox', 'Autofill popover preview on listbox <select>');
+
+test(() => {
+  const select = document.getElementById('listbox-scrollable');
+  assert_equals(select.scrollTop, 0);
+
+  // Previewing an option beyond the visible items does not scroll the listbox.
+  internals.setSuggestedValue(select, 'v8');
+  assert_equals(select.scrollTop, 0, 'scrollTop should remain 0 when previewing off-screen option');
+
+  internals.setSuggestedValue(select, '');
+  assert_equals(select.scrollTop, 0, 'scrollTop should remain 0 after clearing preview');
+
+  // User scrolls the listbox.
+  select.scrollTop = 30;
+  const userScrollTop = select.scrollTop;
+  assert_greater_than(userScrollTop, 0, 'listbox should be scrolled');
+
+  // Previewing does not modify or mask the user scroll position.
+  internals.setSuggestedValue(select, 'v8');
+  assert_equals(select.scrollTop, userScrollTop, 'scrollTop should match user scroll position during preview');
+
+  // Ending preview preserves the user scroll position without resetting.
+  internals.setSuggestedValue(select, '');
+  assert_equals(select.scrollTop, userScrollTop, 'scrollTop should be preserved after clearing preview');
+}, 'Autofill popover preview does not scroll or reset listbox scroll position');
+</script>
+
diff --git a/third_party/blink/web_tests/fast/forms/select/select-autofill-scroll-oracle.html b/third_party/blink/web_tests/fast/forms/select/select-autofill-scroll-oracle.html
new file mode 100644
index 0000000..6088baf7
--- /dev/null
+++ b/third_party/blink/web_tests/fast/forms/select/select-autofill-scroll-oracle.html
@@ -0,0 +1,103 @@
+<!DOCTYPE html>
+<link rel="author" href="mailto:jochen@chromium.org">
+<link rel="help" href="https://issues.chromium.org/issues/40060525">
+<script src="../../../resources/testharness.js"></script>
+<script src="../../../resources/testharnessreport.js"></script>
+<script src="../resources/common.js"></script>
+<style>
+  select {
+    width: 200px;
+    height: 100px;
+  }
+</style>
+
+<select id="listbox" size="4">
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Autofill preview select option leak via ::scroll-button CSS states

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 potential security vulnerability exists where a malicious website can leak a user’s previewed Autofill options within a <select size=N> element before they accept the suggestion. This is caused by ScrollButtonPseudoElement::UpdateSnapshot querying the unmasked scroll position of the originating element without checking if the element is currently in an Autofill preview state. An attacker could potentially observe the :disabled or :enabled state changes on the ::scroll-button pseudo-element to reconstruct the previewed value.

Affected files:

  • third_party/blink/renderer/core/dom/scroll_button_pseudo_element.cc
  • third_party/blink/renderer/core/dom/scroll_button_pseudo_element.h

Estimated timestamp from git blame: 2024-12-03

Root Cause Analysis

ScrollButtonPseudoElement::UpdateSnapshot() — a PostLayoutSnapshotClient that runs after every layout — reads its originating element’s live PaintLayerScrollableArea::ScrollPosition() and derives the author-observable :disabled/:enabled state. However, it does not check if the element is currently in an Autofill preview state (IsPreviewed()):

// third_party/blink/renderer/core/dom/scroll_button_pseudo_element.cc:188-254
bool ScrollButtonPseudoElement::UpdateSnapshot() {
  ...
  LayoutBox* scroller =
      DynamicTo<LayoutBox>(UltimateOriginatingElement().GetLayoutObject());
  if (!scroller ||
      (!scroller->IsScrollContainer() && !scroller->IsDocumentElement())) {
    ... 
  }
  ScrollableArea* scrollable_area = ... scroller->GetScrollableArea();
  ScrollOffset current_position = gfx::ToRoundedVector2d(
      scrollable_area->ScrollPosition().OffsetFromOrigin());   // <-- unmasked read
  ...
  if (mapping.Top()) {
    enabled_ = current_position.y() >
               CalculateSnappedScrollPosition(scrollable_area, kScrollUp).y();
  } else if (mapping.Bottom()) {
    enabled_ = current_position.y() <
               CalculateSnappedScrollPosition(scrollable_area, kScrollDown).y();
  }
  if (enabled != enabled_) SetNeedsStyleRecalc(kLocalStyleChange, ...);
}

The enabled_ state is directly exposed to CSS selector matching:

// third_party/blink/renderer/core/dom/scroll_button_pseudo_element.h:29-32
bool IsEnabled() const { return enabled_; }
bool IsDisabledFormControl() const final { return !IsEnabled(); }
bool MatchesDisabledPseudoClass() const final { return !IsEnabled(); }
bool MatchesEnabledPseudoClass() const final { return IsEnabled(); }

Potential Bypass of Existing Mitigations

Chromium previously resolved a similar leak vector (crbug.com/1261689) by masking the scroll offset for previewed select elements inside the Element::scrollTop getter and ScrollStateQuerySnapshot::CanExposeScrollOffsets(). However, neither of these gates applies to the new ScrollButtonPseudoElement, which directly reads the raw PaintLayerScrollableArea scroll offset.

Potential Trigger Path (Theoretical Sequence)

An attacker could potentially execute the following steps to exploit this leak:

  1. Render a form containing <select size=4 name="country" autocomplete="country"> (and potentially multiple sibling selects containing different bisections of the option list to perform binary search).
  2. Style the scroll buttons:
    select[size]::scroll-button(block-start) { content: ""; }
    select[size]::scroll-button(block-end)   { content: ""; }
    select[size]::scroll-button(block-start):disabled { color: rgb(1, 0, 0); }
    select[size]::scroll-button(block-end):disabled   { color: rgb(0, 1, 0); }
    
  3. Set up a JavaScript polling loop using requestAnimationFrame that queries getComputedStyle(select, "::scroll-button(block-start)").color.
  4. When the user hovers over an Autofill suggestion, Autofill previews the suggested option via WebFormControlElement::SetSuggestedOption.
  5. The renderer updates the select element’s state to WebAutofillState::kPreviewed and performs a programmatic ScrollIntoView to center the previewed option.
  6. During the layout lifecycle update, ScrollButtonPseudoElement::UpdateSnapshot() executes, reads the unmasked preview scroll position, and recalculates the :disabled / :enabled state of the pseudo-element.
  7. The attacker’s script reads the updated style (e.g., the color change on :disabled), leaking whether the previewed option fell within the top/bottom visible pages of the listbox, thereby reconstructing the user’s private data.

Note: Our tooling does not yet have the ability to run code, so these are suggested/potential steps to demonstrate the vulnerability path.

Suggested Fix

To remediate this potential leak, ScrollButtonPseudoElement::UpdateSnapshot() should check if the originating element is currently in an Autofill preview state. If it is, the scroll position must be masked (for instance, by treating the scroll offset as zero or returning early and preserving the default state):

auto* originating_form_control = DynamicTo<HTMLFormControlElement>(UltimateOriginatingElement());
if (originating_form_control && originating_form_control->IsPreviewed()) {
  // Mask scroll position to prevent leaking the previewed option
  return false;
}

Evaluated with Chrome root at commit: 94d9235ebe3b7276e5284f0dc5d55577ff949908


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
Links in the report