CVE-2026-87566
Overview
Background
- Autofill preview state
- the transient state in which the browser shows a suggested value inside a form control (e.g. a
<select>) that the user has not yet accepted, exposed in Blink asHTMLFormControlElement::IsPreviewed()/HTMLSelectElement::SetSuggestedValue(). - Listbox vs menulist `<select>`
- a
<select size=N>renders as a scrollable listbox (!UsesMenuList()), whereas a plain<select>renders as a collapsed menulist button; the two are handled byListBoxSelectTypeandMenuListSelectType. - Scroll-position masking
- pre-patch defenses that hide a previewed listbox’s scroll offset from script by making the scroll getter in
element.ccreturn0and by freezing the selected scroll marker inscroll_marker_group_data.cc. - Top-layer popover preview
- the new mechanism (
SelectAutofillPopoverPreview) that renders the suggested value in aSelectAutofillPreviewElementpopover overlay (autofill_popover_/autofill_popover_text_) instead of scrolling the real control.
Root Cause Analysis
Before this change, autofill preview for a listbox <select> was displayed by physically scrolling the listbox so the suggested option came into view, and the suggested value was allowed to flow into the rendered content via MenuListSelectType::OptionToBeShown() / the listbox scroll. Because the previewed value must never be observable to the page until the user accepts it, Blink relied on a patchwork of per-API masks — the scroll getter in element.cc returning 0 for a previewed listbox, and scroll_marker_group_data.cc keeping the previously selected scroll marker — to conceal that the control had scrolled. That whitelist-style masking was incomplete: the control’s layout was genuinely mutated, so an observable discrepancy remained through layout channels the masks did not cover, letting a page infer which option (and thus the suggested autofill value) had been scrolled into view.
The fix introduces SelectType::CreateAutofillPopover() and a unified SelectType::DidSetSuggestedOption() so that both listbox and menulist previews are painted in a top-layer popover overlay without touching the control’s own scroll position, and it routes ListBoxSelectType::DidSetSuggestedOption() and MenuListSelectType::OptionToBeShown() to no longer scroll or leak the suggested option when the feature is enabled. Because the listbox is never scrolled, there is no scroll state to disclose, so the element.cc and scroll_marker_group_data.cc masks are correctly gated behind !SelectAutofillPopoverPreviewEnabled() and retained only for the legacy fallback.
Attack Path
- Provision a scrollable listbox
A malicious page presents a listbox
<select size=N>with enough options that a suggested value would require scrolling, into which the browser will offer an autofill suggestion. - Trigger a preview
Autofill enters preview state (
IsPreviewed()true), and the pre-patch code scrolls the listbox to bring the suggested option into view while maskingscrollTopto0and freezing the selected scroll marker. - Observe the residual discrepancy
The page measures the control’s mutated layout through an observation channel not covered by the masks (inferred: e.g. scroll-marker/
:checkedstate, geometry of an option, or intersection observation) to determine which option was scrolled into view. - Recover the suggested value From the inferred scroll offset the page maps back to the previewed option, disclosing the autofill-suggested value before the user consents to it.
Impact Assessment
<select> before the user accepts it, disclosing potentially sensitive user data (e.g. profile or form entries) within the renderer/page context. The precondition is that the user has an autofill suggestion offered for a listbox-rendered <select> on the attacker’s page; no user acceptance of the suggestion is required. The advisory classifies this as an information-disclosure “observable discrepancy in Layout”; no exploited-in-the-wild status or escalation beyond information disclosure is claimed by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
GetAutofillPreviewElementthird_party/blink/renderer/core/html/forms/select_type.cc |
modified | |
ifthird_party/blink/renderer/core/html/forms/select_type.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/html/forms/html_select_element_test.cc |
modified |
Files Changed
third_party/blink/renderer/platform/runtime_enabled_features.json5third_party/blink/renderer/core/html/forms/select_type.ccthird_party/blink/web_tests/fast/forms/suggested-value-after-empty-suggested-value-expected.txtthird_party/blink/web_tests/fast/forms/select/select-autofill-popover-preview.htmlthird_party/blink/renderer/core/html/resources/html.cssthird_party/blink/renderer/core/dom/scroll_marker_group_data.ccthird_party/blink/renderer/core/html/forms/select_type.hthird_party/blink/web_tests/fast/forms/select/select-autofill-scroll-oracle.htmlthird_party/blink/renderer/core/html/forms/html_select_element_test.ccthird_party/blink/renderer/core/dom/element.cc
Audit Directions
- Layout-mutating previews with ad-hoc maskingAudit any feature that renders unaccepted/privileged data by mutating a real element’s layout (scroll offset, selection, geometry) and then masks a single observation API; enumerate every layout-observation channel rather than trusting a per-API whitelist.
- Feature-flag gating of security masksReview changes that place an existing security guard behind a
RuntimeEnabledFeaturescheck and confirm the new default code path removes the underlying observable, not just the check, for both enabled and disabled states. - Autofill/preview surface across control typesCompare
MenuListSelectType,ListBoxSelectType, and other form controls to ensure preview state (IsPreviewed()) is uniformly non-observable, since divergent per-subtype handling (e.g. the formernullptrGetAutofillPreviewElement()) is where leaks arise.
Patch
From b12935e03a81ffbfcbb68a792dc9d51ab6b968ea Mon Sep 17 00:00:00 2001 From: Jochen Eisinger <[email protected]> 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 <[email protected]> Reviewed-by: Joey Arhar <[email protected]> Reviewed-by: Christoph Schwering <[email protected]> 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)
Regression Test / PoC
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:[email protected]">
+<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:[email protected]">
+<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)
Original Bug Report
Autofill preview selection leak via default position-visibility and elementFromPoint
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: During an Autofill preview on a select element, the listbox is programmatically scrolled to the suggested option. Under default position-visibility behavior, this scroll triggers a UA-internal IntersectionObserver that updates the visibility and hit-testability of anchored probe elements. A malicious webpage can observe which probes are visible via document.elementFromPoint(), leaking the previewed option before user acceptance.
Affected files:
third_party/blink/renderer/core/layout/anchor_position_visibility_observer.ccthird_party/blink/renderer/core/html/forms/select_type.ccthird_party/blink/renderer/core/layout/out_of_flow_layout_part.ccthird_party/blink/renderer/core/paint/paint_layer.ccthird_party/blink/renderer/core/dom/tree_scope.cc
Estimated timestamp from git blame: 2024-04-03
Root Cause Analysis
A potential side-channel leak exists in the interaction between Blink’s Autofill preview scrolling and CSS anchor-positioned visibility tracking.
When a user hovers over an Autofill autofill suggestion, ListBoxSelectType::DidSetSuggestedOption performs a programmatic scroll to the previewed <option> element:
- File:
third_party/blink/renderer/core/html/forms/select_type.cc - Method:
ListBoxSelectType::ScrollToOptionTask()
box->Layer()->GetScrollableArea()->ScrollIntoView(
bounds, PhysicalBoxStrut(),
scroll_into_view_util::CreateScrollIntoViewParams(
ScrollAlignment::ToEdgeIfNeeded(), ScrollAlignment::ToEdgeIfNeeded(),
mojom::blink::ScrollType::kProgrammatic, false,
mojom::blink::ScrollBehavior::kInstant), nullptr);
While standard DOM scroll properties are masked during Autofill previews to prevent leakage, this programmatic scroll changes the layout/scroll position of the select’s scroll port.
Under the default position-visibility: anchors-visible behavior, OutOfFlowLayoutPart creates an AnchorPositionVisibilityObserver on any anchored elements:
- File:
third_party/blink/renderer/core/layout/out_of_flow_layout_part.cc
if (is_anchor_positioned && has_anchors_visible_visibility && anchor) {
anchored->EnsureAnchorPositionScrollData()
.EnsureAnchorPositionVisibilityObserver()
.MonitorAnchor(anchor);
}
In AnchorPositionVisibilityObserver::MonitorAnchor, an internal IntersectionObserver is set up with the <select> layout container as the intersection_root and the <option> element as the target:
- File:
third_party/blink/renderer/core/layout/anchor_position_visibility_observer.cc
observer_ = IntersectionObserver::Create(
anchor_element_->GetDocument(), ...,
IntersectionObserver::Params{
.root = intersection_root->GetNode(),
.thresholds = {IntersectionObserver::kMinimumThreshold},
.behavior = IntersectionObserver::kDeliverDuringPostLayoutSteps });
observer_->observe(const_cast<Element*>(anchor_element_.Get()));
There are no IsPreviewed() checks on this path. When the listbox scrolls to the previewed option, the internal IntersectionObserver updates the anchored probe’s layer visibility flag (invisible_for_position_visibility_) via PaintLayer::SetInvisibleForPositionVisibility.
During hit-testing, PaintLayer::HitTestLayer skips layers marked with this visibility flag:
- File:
third_party/blink/renderer/core/paint/paint_layer.cc
if (InvisibleForPositionVisibility() ||
HasAncestorInvisibleForPositionVisibility()) {
return nullptr; // elementFromPoint() falls through to the element behind
}
By querying document.elementFromPoint(), the page’s JavaScript can programmatically discover which probe layers have been skipped (i.e. are clipped out), leaking the precise previewed option.
Potential Exploit Scenario
Note: The following are suggested/potential steps as our tooling agent does not currently have the ability to run or execute code.
- A page serves a form containing
<select size="4" autocomplete="country">with multiple options<option id="optK">. - The page styles the options and registers fixed probe elements:
#optK { anchor-name: --anchorK; } #probeK { position: fixed; position-anchor: --anchorK; left: anchor(left); top: 0; width: 4px; height: 4px; z-index: 9999; } - When the user hovers over an Autofill suggestion, the listbox is scrolled programmatically to the suggested option.
- The internal
IntersectionObservertriggers, settinginvisible_for_position_visibility_to true on probes associated with options that are clipped out of the select’s visible area. - Malicious JavaScript polls
document.elementFromPoint(K*4+2, 2)perrequestAnimationFrame. For clipped options, the hit-test falls through, returning the background element instead ofprobeK. - The set of visible probes uniquely identifies the active Autofill preview choice.
Suggested Remediation
To mitigate this issue, ensure that visibility updates driven by the UA-internal IntersectionObserver in AnchorPositionVisibilityObserver respect the Autofill preview state.
When the target anchor (or its containing form control) is in a previewed state (e.g., HTMLSelectElement::IsPreviewed() is true), the observer should avoid applying the updated visibility state to the layer, or ensure that calculations use the un-previewed scroll offset. Alternatively, do not trigger/deliver internal visibility updates if the root element is currently under an active Autofill preview.
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.