CVE-2026-14012
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/html/forms/html_text_area_element_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/css/scroll_state_query_snapshot.ccthird_party/blink/renderer/core/css/scroll_state_query_snapshot.hthird_party/blink/renderer/core/html/forms/html_text_area_element_test.cc
Patch
From 01bcb7ead0c7c934a0bfcbb5507dd8518f81271c Mon Sep 17 00:00:00 2001
From: Rune Lillesveen <futhark@chromium.org>
Date: Thu, 28 May 2026 01:59:07 -0700
Subject: [PATCH] Disallow scroll-state() queries on autofill preview effects
Could potentially be used to leak information about autofill data.
Treat autofill-previewed form controls as if they are not scroll
containers for scroll-state() queries for scrolled/scrollable.
Bug: 517110749
Change-Id: I6afceb3948c81e357d2efa69414b999955413c05
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7879906
Commit-Queue: Rune Lillesveen <futhark@chromium.org>
Reviewed-by: Kent Tamura <tkent@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1637553}
---
diff --git a/third_party/blink/renderer/core/css/scroll_state_query_snapshot.cc b/third_party/blink/renderer/core/css/scroll_state_query_snapshot.cc
index bbebca04..8fe275ed 100644
--- a/third_party/blink/renderer/core/css/scroll_state_query_snapshot.cc
+++ b/third_party/blink/renderer/core/css/scroll_state_query_snapshot.cc
@@ -50,8 +50,9 @@
if (layout_object->IsDocumentElement()) {
layout_object = layout_object->View();
}
- if (PaintLayerScrollableArea* scrollable_area =
- layout_object->GetScrollableArea()) {
+ PaintLayerScrollableArea* scrollable_area =
+ layout_object->GetScrollableArea();
+ if (scrollable_area && CanExposeScrollOffsets()) {
ScrollOffset max_offset = scrollable_area->MaximumScrollOffset();
ScrollOffset min_offset = scrollable_area->MinimumScrollOffset();
ScrollOffset offset = scrollable_area->GetScrollOffset();
@@ -109,6 +110,14 @@
return false;
}
+bool ScrollStateQuerySnapshot::CanExposeScrollOffsets() {
+ HTMLFormControlElement* form_control =
+ DynamicTo<HTMLFormControlElement>(container_.Get());
+ // Autofill preview rendering could expose private data via text size and
+ // scrollable overflow.
+ return !form_control || !form_control->IsPreviewed();
+}
+
void ScrollStateQuerySnapshot::Trace(Visitor* visitor) const {
visitor->Trace(container_);
PostLayoutSnapshotClient::Trace(visitor);
diff --git a/third_party/blink/renderer/core/css/scroll_state_query_snapshot.h b/third_party/blink/renderer/core/css/scroll_state_query_snapshot.h
index e17076d..b44e6c9 100644
--- a/third_party/blink/renderer/core/css/scroll_state_query_snapshot.h
+++ b/third_party/blink/renderer/core/css/scroll_state_query_snapshot.h
@@ -47,6 +47,8 @@
void Trace(Visitor* visitor) const override;
private:
+ bool CanExposeScrollOffsets();
+
Member<Element> container_;
ContainerStuckPhysical stuck_horizontal_ = ContainerStuckPhysical::kNo;
ContainerStuckPhysical stuck_vertical_ = ContainerStuckPhysical::kNo;
diff --git a/third_party/blink/renderer/core/html/forms/html_text_area_element_test.cc b/third_party/blink/renderer/core/html/forms/html_text_area_element_test.cc
index 3432ebc..e8f2fce0 100644
--- a/third_party/blink/renderer/core/html/forms/html_text_area_element_test.cc
+++ b/third_party/blink/renderer/core/html/forms/html_text_area_element_test.cc
@@ -11,10 +11,13 @@
#include "base/strings/to_string.h"
#include "testing/gmock/include/gmock/gmock-matchers.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/core/css/properties/longhands.h"
#include "third_party/blink/renderer/core/dom/text.h"
+#include "third_party/blink/renderer/core/style/computed_style.h"
#include "third_party/blink/renderer/core/testing/core_unit_test_helper.h"
#include "third_party/blink/renderer/core/testing/mock_clipboard_host.h"
#include "third_party/blink/renderer/platform/bindings/exception_state.h"
+#include "third_party/blink/renderer/platform/graphics/color.h"
#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
#include "third_party/skia/include/core/SkFontTypes.h"
@@ -611,4 +614,44 @@
EXPECT_EQ(preview_scroll_left_2, 0);
}
+TEST_F(HTMLTextAreaElementTest, AutofillPreviewScrollStateLeak) {
+ SetBodyContent(R"HTML(
+ <style>
+ textarea {
+ width: 30px;
+ height: 100px;
+ letter-spacing: 2000px;
+ overflow: auto;
+ writing-mode: vertical-lr;
+ container-type: scroll-state;
+ }
+ textarea::before { color: green; }
+ @container scroll-state(scrollable) {
+ textarea::before { color: lime; }
+ }
+ </style>
+ <textarea id="test"></textarea>
+ )HTML");
+ HTMLTextAreaElement& textarea = TestElement();
+ RunDocumentLifecycle();
+
+ // Set suggested value (simulate autofill preview)
+ textarea.SetSuggestedValue("XXXXXXXXXX");
+ RunDocumentLifecycle();
+
+ const ComputedStyle* autofill_style =
+ textarea.EnsureComputedStyle(kPseudoIdBefore);
+ EXPECT_EQ(autofill_style->VisitedDependentColor(GetCSSPropertyColor()),
+ Color::FromRGB(0, 128, 0));
+
+ // Scroll-state query should match after replacing autofill preview
+ textarea.SetValue("XXXXXXXXXX");
+ RunDocumentLifecycle();
+
+ const ComputedStyle* non_autofill_style =
+ textarea.EnsureComputedStyle(kPseudoIdBefore);
+ EXPECT_EQ(non_autofill_style->VisitedDependentColor(GetCSSPropertyColor()),
+ Color::FromRGB(0, 255, 0));
+}
+
} // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/html/forms/html_text_area_element_test.cc b/third_party/blink/renderer/core/html/forms/html_text_area_element_test.cc
index 3432ebc..e8f2fce0 100644
--- a/third_party/blink/renderer/core/html/forms/html_text_area_element_test.cc
+++ b/third_party/blink/renderer/core/html/forms/html_text_area_element_test.cc
@@ -11,10 +11,13 @@
#include "base/strings/to_string.h"
#include "testing/gmock/include/gmock/gmock-matchers.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/core/css/properties/longhands.h"
#include "third_party/blink/renderer/core/dom/text.h"
+#include "third_party/blink/renderer/core/style/computed_style.h"
#include "third_party/blink/renderer/core/testing/core_unit_test_helper.h"
#include "third_party/blink/renderer/core/testing/mock_clipboard_host.h"
#include "third_party/blink/renderer/platform/bindings/exception_state.h"
+#include "third_party/blink/renderer/platform/graphics/color.h"
#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
#include "third_party/skia/include/core/SkFontTypes.h"
@@ -611,4 +614,44 @@
EXPECT_EQ(preview_scroll_left_2, 0);
}
+TEST_F(HTMLTextAreaElementTest, AutofillPreviewScrollStateLeak) {
+ SetBodyContent(R"HTML(
+ <style>
+ textarea {
+ width: 30px;
+ height: 100px;
+ letter-spacing: 2000px;
+ overflow: auto;
+ writing-mode: vertical-lr;
+ container-type: scroll-state;
+ }
+ textarea::before { color: green; }
+ @container scroll-state(scrollable) {
+ textarea::before { color: lime; }
+ }
+ </style>
+ <textarea id="test"></textarea>
+ )HTML");
+ HTMLTextAreaElement& textarea = TestElement();
+ RunDocumentLifecycle();
+
+ // Set suggested value (simulate autofill preview)
+ textarea.SetSuggestedValue("XXXXXXXXXX");
+ RunDocumentLifecycle();
+
+ const ComputedStyle* autofill_style =
+ textarea.EnsureComputedStyle(kPseudoIdBefore);
+ EXPECT_EQ(autofill_style->VisitedDependentColor(GetCSSPropertyColor()),
+ Color::FromRGB(0, 128, 0));
+
+ // Scroll-state query should match after replacing autofill preview
+ textarea.SetValue("XXXXXXXXXX");
+ RunDocumentLifecycle();
+
+ const ComputedStyle* non_autofill_style =
+ textarea.EnsureComputedStyle(kPseudoIdBefore);
+ EXPECT_EQ(non_autofill_style->VisitedDependentColor(GetCSSPropertyColor()),
+ Color::FromRGB(0, 255, 0));
+}
+
} // namespace blink
Original Bug Report
Autofill preview data leak via CSS scroll-state container queries
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: CSS scroll-state container queries retrieve scroll-state metrics directly from the PaintLayerScrollableArea layout object, bypassing JavaScript-level autofill preview masks. This potentially allows a malicious page to extract the precise character length of suggested textarea values or the selected index of a listbox dropdown when a user hovers over an autofill suggestion. This logical side-channel leak bypasses the privacy guarantees of autocomplete preview states.
Affected files:
third_party/blink/renderer/core/css/scroll_state_query_snapshot.ccthird_party/blink/renderer/core/html/forms/html_text_area_element.ccthird_party/blink/renderer/core/dom/element.cc
Estimated timestamp from git blame: 2024-10-21
Root Cause Analysis
To prevent malicious websites from learning autofill suggestions prior to user acceptance (i.e., when the user merely hovers over an autofill suggestion), Chromium overrides virtual scroll-metric getters on elements to return fake or masked values if a preview or suggested value is present:
- For
<textarea>,HTMLTextAreaElement::scrollWidth(),scrollHeight(),scrollLeft(), andscrollTop()return fake or masked values ifSuggestedValue()is not empty. - For listbox
<select>elements,Element::scrollTop()returns0if the select is non-menu andIsPreviewed()is true.
However, these protective masks are only enforced at the virtual JavaScript getter layer (e.g., Element::scroll*()). The CSS @container scroll-state(scrollable: ...) evaluation machinery bypasses these masks entirely by directly querying the PaintLayerScrollableArea (PLSA) metrics in ScrollStateQuerySnapshot::UpdateSnapshot() (third_party/blink/renderer/core/css/scroll_state_query_snapshot.cc):
if (PaintLayerScrollableArea* scrollable_area =
layout_object->GetScrollableArea()) {
ScrollOffset max_offset = scrollable_area->MaximumScrollOffset(); // Unmasked
ScrollOffset min_offset = scrollable_area->MinimumScrollOffset();
ScrollOffset offset = scrollable_area->GetScrollOffset(); // Unmasked
...
if (offset.y() > min_offset.y())
scrollable_vertical |= ContainerScrollable::kStart; // Leads to scroll-state(scrollable: top)
if (offset.y() < max_offset.y())
scrollable_vertical |= ContainerScrollable::kEnd; // Leads to scroll-state(scrollable: bottom)
}
Because the underlying PLSA scroll offset and layout dimensions are modified during the autofill preview state, this direct query allows an attacker to leak sensitive information via CSS container queries.
Suggested Exploit Scenarios (Potential Steps)
Note: These are potential steps based on source code analysis; our tooling does not currently have the capability to execute code or run interactive tests.
1. Listbox <select> (Option Index Leak)
During preview, ListBoxSelectType::DidSetSuggestedOption triggers a scroll task that brings the previewed option into view by calling ScrollToOption on the underlying PaintLayerScrollableArea. This mutates the PLSA scroll offset.
Although the JavaScript getter Element::scrollTop() returns 0 due to the preview mask, the real scroll offset inside the PLSA is updated. A container-query of @container scroll-state(scrollable: top) on the select container will evaluate to true based on the real offset, allowing styles (such as background colors) to be applied to descendant <option> elements. The page can read back these styles using getComputedStyle(option) to infer the index of the suggested option (such as country, birth-month, or card type).
2. <textarea> (Character Length Leak)
During preview, HTMLTextAreaElement::SetSuggestedValue creates a user-agent shadow <div> containing the suggested value. This placeholder fragment is rendered at its full height, causing the textarea’s PLSA MaximumScrollOffset().y() to grow proportionally with the length of the suggested value.
Since Element::setScrollTop is non-virtual and does not have any preview checks, the page can set scrollTop programmatically, which is internally clamped to [0, MaximumScrollOffset().y()]. By setting the scroll offset to a target value K in a binary-search loop across animation frames and observing whether @container scroll-state(scrollable: bottom) triggers layout updates (e.g., modifying a custom scrollbar width and thus altering clientWidth), the page can binary-search the exact MaximumScrollOffset().y() and reconstruct the precise character length of the previewed string.
Suggested Fix
In ScrollStateQuerySnapshot::UpdateSnapshot() (third_party/blink/renderer/core/css/scroll_state_query_snapshot.cc), before querying the PaintLayerScrollableArea metrics, verify whether the container element is in an active autofill preview state (e.g., by checking Element::IsPreviewed() or if SuggestedValue() is non-empty for text controls). If a preview is active, apply the same masking principles used by the virtual JS getters (e.g., clamp the queryable offsets or return zero/faked layout metrics) so that CSS container queries do not expose layout alterations caused by suggested preview text.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.