CVE-2026-112995
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/animation/scroll_snapshot_timeline.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/animation/scroll_timeline_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/animation/scroll_snapshot_timeline.ccthird_party/blink/renderer/core/animation/scroll_timeline.hthird_party/blink/renderer/core/animation/scroll_timeline_test.ccthird_party/blink/renderer/core/animation/scroll_snapshot_timeline.h
Patch
From 26318dfdb9210790fa109a38abca1ca4d4229cf9 Mon Sep 17 00:00:00 2001 From: Free Debreuil <[email protected]> Date: Thu, 17 Sep 2026 07:27:41 -0700 Subject: [PATCH] [blink] Pause scroll timelines on autofill preview Skip the snapshot update of a scroll timeline while its scroll container belongs to a form control showing an autofill preview. The preview replaces the rendered contents of the control, so a timeline sampled during one tracks the suggestion rather than the value the page set. Retaining the previous snapshot pauses the timeline rather than deactivating it, so attached animations stay in effect instead of snapping back to their base style. Skip scheduling the next service while paused so that preview geometry does not request an animation frame on every vsync. Resolve the scroll container to its owning control with GetAncestorFormControlElement, which reuses ScrollTimeline::Sanitize to unwrap arbitrarily nested pseudo-elements and user agent shadow trees. Bug: 514447367 Change-Id: Iba283105c3e4beb68018e3bdbb6e716c1b6cbcdd Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8378297 Reviewed-by: Kevin Ellis <[email protected]> Auto-Submit: Free Debreuil <[email protected]> Commit-Queue: Free Debreuil <[email protected]> Cr-Commit-Position: refs/heads/main@{#1699777} --- diff --git a/third_party/blink/renderer/core/animation/scroll_snapshot_timeline.cc b/third_party/blink/renderer/core/animation/scroll_snapshot_timeline.cc index 44e1bfa..9153b58 100644 --- a/third_party/blink/renderer/core/animation/scroll_snapshot_timeline.cc +++ b/third_party/blink/renderer/core/animation/scroll_snapshot_timeline.cc @@ -7,10 +7,13 @@ #include <optional> #include "third_party/blink/renderer/bindings/core/v8/v8_union_cssnumericvalue_double.h" +#include "third_party/blink/renderer/core/animation/scroll_timeline.h" #include "third_party/blink/renderer/core/animation/scroll_timeline_util.h" #include "third_party/blink/renderer/core/animation/timeline_trigger.h" #include "third_party/blink/renderer/core/css/cssom/css_unit_values.h" #include "third_party/blink/renderer/core/dom/node.h" +#include "third_party/blink/renderer/core/html/forms/html_form_control_element.h" +#include "third_party/blink/renderer/core/html/forms/text_control_element.h" #include "third_party/blink/renderer/core/layout/forms/layout_fieldset.h" #include "third_party/blink/renderer/core/layout/geometry/axis.h" #include "third_party/blink/renderer/core/layout/layout_box.h" @@ -20,6 +23,16 @@ namespace blink { +namespace { + +const HTMLFormControlElement* GetAncestorFormControlElement(Node* node) { + // Unwrap pseudo-elements and UA shadow trees to find the owning control. + return DynamicTo<HTMLFormControlElement>( + ScrollTimeline::Sanitize(DynamicTo<Element>(node))); +} + +} // namespace + ScrollSnapshotTimeline::ScrollSnapshotTimeline(Document* document) : AnimationTimeline(document), PostLayoutSnapshotClient(document->GetFrame()) {} @@ -154,6 +167,11 @@ } auto state = ComputeTimelineState(); + // The state above tracks the preview, so comparing it against the retained + // snapshot would request a frame on every vsync. + if (BlockSnapshotUpdate(state.resolved_source)) { + return false; + } std::optional<base::TimeDelta> current_time = state.current_time; return current_time != last_current_time_; } @@ -163,7 +181,6 @@ NOTREACHED(); } - LayoutBox* ScrollSnapshotTimeline::ComputeScrollContainer( Node* resolved_source, PhysicalAxis physical_axis) { @@ -203,6 +220,10 @@ bool ScrollSnapshotTimeline::UpdateSnapshotInternal(bool service_animations) { TimelineState new_state = ComputeTimelineState(); + if (BlockSnapshotUpdate(new_state.resolved_source)) { + // Retain the previous snapshot so the timeline stays paused. + return false; + } bool snapshot_changed = timeline_state_snapshotted_ != new_state; bool layout_changed = !timeline_state_snapshotted_.HasConsistentLayout(new_state); @@ -269,6 +290,18 @@ return snapshot_changed; } +bool ScrollSnapshotTimeline::BlockSnapshotUpdate(Node* resolved_source) const { + const auto* control = GetAncestorFormControlElement(resolved_source); + if (!control) { + return false; + } + // Password autofill sets a suggested value while marked kAutofilled, so a + // preview is not always flagged as previewed. + const auto* text_control = DynamicTo<TextControlElement>(control); + return control->IsPreviewed() || + (text_control && !text_control->SuggestedValue().empty()); +} + cc::AnimationTimeline* ScrollSnapshotTimeline::EnsureCompositorTimeline() { if (compositor_timeline_) { return compositor_timeline_.get(); diff --git a/third_party/blink/renderer/core/animation/scroll_snapshot_timeline.h b/third_party/blink/renderer/core/animation/scroll_snapshot_timeline.h index 81fda950..14759d9 100644 --- a/third_party/blink/renderer/core/animation/scroll_snapshot_timeline.h +++ b/third_party/blink/renderer/core/animation/scroll_snapshot_timeline.h @@ -219,6 +219,10 @@ private: bool UpdateSnapshotInternal(bool service_animations); + // Returns true if the resolved source belongs to a form control showing an + // autofill preview, whose rendered contents must not be sampled. + bool BlockSnapshotUpdate(Node* resolved_source) const; + // Snapshotted value produced by the last SnapshotState call. TimelineState timeline_state_snapshotted_; diff --git a/third_party/blink/renderer/core/animation/scroll_timeline.h b/third_party/blink/renderer/core/animation/scroll_timeline.h index c3c27945..758a2b42 100644 --- a/third_party/blink/renderer/core/animation/scroll_timeline.h +++ b/third_party/blink/renderer/core/animation/scroll_timeline.h @@ -102,12 +102,12 @@ // [2] https://drafts.csswg.org/scroll-animations-1/#dom-viewtimeline-subject Element* GetReferenceElement() const { return reference_element_.Get(); } - protected: // Returns an element eligible to be web exposed. This may be the element // itself, the ultimate owning element in the case of a pseudo-element, or // shadow host in the case of UA shadow content. static Element* Sanitize(Element* element); + protected: // Scroll offsets corresponding to 0% and 100% progress. By default, these // correspond to the scroll range of the container. virtual void CalculateOffsets(PaintLayerScrollableArea* scrollable_area, diff --git a/third_party/blink/renderer/core/animation/scroll_timeline_test.cc b/third_party/blink/renderer/core/animation/scroll_timeline_test.cc index 9cedf180..cc93c79 100644 --- a/third_party/blink/renderer/core/animation/scroll_timeline_test.cc +++ b/third_party/blink/renderer/core/animation/scroll_timeline_test.cc @@ -20,6 +20,9 @@ #include "third_party/blink/renderer/core/css/resolver/style_resolver.h" #include "third_party/blink/renderer/core/dom/dom_token_list.h" #include "third_party/blink/renderer/core/dom/events/native_event_listener.h" +#include "third_party/blink/renderer/core/dom/pseudo_element.h" +#include "third_party/blink/renderer/core/html/forms/html_select_element.h" +#include "third_party/blink/renderer/core/html/forms/html_text_area_element.h" #include "third_party/blink/renderer/core/page/page_animator.h" #include "third_party/blink/renderer/core/paint/paint_layer.h" #include "third_party/blink/renderer/core/paint/paint_layer_scrollable_area.h" @@ -1184,4 +1187,87 @@ EXPECT_FALSE(deferred_timeline->CompositorTimeline()); } +TEST_F(ScrollTimelineTest, AutofillPreviewPausesTimeline) { + SetBodyInnerHTML(R"HTML( + <style> + #textarea { width: 100px; height: 50px; } + </style> + <textarea id='textarea'></textarea> + )HTML"); + + auto* textarea = To<HTMLTextAreaElement>(GetElementById("textarea")); + textarea->SetValue("one\ntwo\nthree\nfour\nfive\nsix\nseven\neight"); + UpdateAllLifecyclePhasesForTest(); + + auto* timeline = + MakeGarbageCollected<TestScrollTimeline>(&GetDocument(), textarea); + textarea->setScrollTop(10); + UpdateAllLifecyclePhasesForTest(); + timeline->UpdateSnapshotForTesting(); + + ASSERT_TRUE(timeline->IsActive()); + std::optional<double> paused_time = timeline->CurrentTimeSeconds(); + ASSERT_TRUE(paused_time.has_value()); + EXPECT_GT(*paused_time, 0); + + // The preview must not be sampled, but the timeline stays active so that its + // animations remain in effect. + textarea->SetSuggestedValue("a\nmuch\nlonger\nsuggested\nvalue\ngoes\nhere"); + UpdateAllLifecyclePhasesForTest(); + timeline->UpdateSnapshotForTesting(); + EXPECT_TRUE(timeline->IsActive()); + EXPECT_EQ(timeline->CurrentTimeSeconds(), paused_time); + + // Scrolling while the preview is showing must not advance the timeline. + textarea->setScrollTop(40); + UpdateAllLifecyclePhasesForTest(); + timeline->UpdateSnapshotForTesting(); + EXPECT_EQ(timeline->CurrentTimeSeconds(), paused_time); + + // Clearing the preview resumes sampling. + textarea->SetSuggestedValue(String()); + UpdateAllLifecyclePhasesForTest(); + timeline->UpdateSnapshotForTesting(); + EXPECT_TRUE(timeline->IsActive()); + EXPECT_NE(timeline->CurrentTimeSeconds(), paused_time); +} + +TEST_F(ScrollTimelineTest, AutofillPreviewBlocksPseudoElementScroller) { + // A <select> is one of the few form controls allowed to generate + // pseudo-elements, so its scroller may be a pseudo-element rather than the + // control itself. See CanHaveGeneratedChildren(). A timeline first sampled + // during a preview has no earlier value to retain, so it stays inactive. + SetBodyInnerHTML(R"HTML( + <style> + #select::before { + content: 'a\A b\A c\A d\A e\A f\A g\A h'; + white-space: pre; + display: block; + overflow: scroll; + width: 50px; + height: 20px; + } + </style> + <select id='select' size='4'> + <option value='one'>one</option> + <option value='two'>two</option> + </select> + )HTML"); + + auto* select = To<HTMLSelectElement>(GetElementById("select")); + select->SetSuggestedValue("two"); + UpdateAllLifecyclePhasesForTest(); + ASSERT_TRUE(select->IsPreviewed()); + + PseudoElement* before = select->GetPseudoElement(kPseudoIdBefore); + ASSERT_TRUE(before); + ASSERT_TRUE(before->GetLayoutBoxForScrolling()); + + auto* timeline = + MakeGarbageCollected<TestScrollTimeline>(&GetDocument(), before); + timeline->UpdateSnapshotForTesting(); + + EXPECT_FALSE(timeline->IsActive()); +} + } // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/animation/scroll_timeline_test.cc b/third_party/blink/renderer/core/animation/scroll_timeline_test.cc
index 9cedf180..cc93c79 100644
--- a/third_party/blink/renderer/core/animation/scroll_timeline_test.cc
+++ b/third_party/blink/renderer/core/animation/scroll_timeline_test.cc
@@ -20,6 +20,9 @@
#include "third_party/blink/renderer/core/css/resolver/style_resolver.h"
#include "third_party/blink/renderer/core/dom/dom_token_list.h"
#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
+#include "third_party/blink/renderer/core/dom/pseudo_element.h"
+#include "third_party/blink/renderer/core/html/forms/html_select_element.h"
+#include "third_party/blink/renderer/core/html/forms/html_text_area_element.h"
#include "third_party/blink/renderer/core/page/page_animator.h"
#include "third_party/blink/renderer/core/paint/paint_layer.h"
#include "third_party/blink/renderer/core/paint/paint_layer_scrollable_area.h"
@@ -1184,4 +1187,87 @@
EXPECT_FALSE(deferred_timeline->CompositorTimeline());
}
+TEST_F(ScrollTimelineTest, AutofillPreviewPausesTimeline) {
+ SetBodyInnerHTML(R"HTML(
+ <style>
+ #textarea { width: 100px; height: 50px; }
+ </style>
+ <textarea id='textarea'></textarea>
+ )HTML");
+
+ auto* textarea = To<HTMLTextAreaElement>(GetElementById("textarea"));
+ textarea->SetValue("one\ntwo\nthree\nfour\nfive\nsix\nseven\neight");
+ UpdateAllLifecyclePhasesForTest();
+
+ auto* timeline =
+ MakeGarbageCollected<TestScrollTimeline>(&GetDocument(), textarea);
+ textarea->setScrollTop(10);
+ UpdateAllLifecyclePhasesForTest();
+ timeline->UpdateSnapshotForTesting();
+
+ ASSERT_TRUE(timeline->IsActive());
+ std::optional<double> paused_time = timeline->CurrentTimeSeconds();
+ ASSERT_TRUE(paused_time.has_value());
+ EXPECT_GT(*paused_time, 0);
+
+ // The preview must not be sampled, but the timeline stays active so that its
+ // animations remain in effect.
+ textarea->SetSuggestedValue("a\nmuch\nlonger\nsuggested\nvalue\ngoes\nhere");
+ UpdateAllLifecyclePhasesForTest();
+ timeline->UpdateSnapshotForTesting();
+ EXPECT_TRUE(timeline->IsActive());
+ EXPECT_EQ(timeline->CurrentTimeSeconds(), paused_time);
+
+ // Scrolling while the preview is showing must not advance the timeline.
+ textarea->setScrollTop(40);
+ UpdateAllLifecyclePhasesForTest();
+ timeline->UpdateSnapshotForTesting();
+ EXPECT_EQ(timeline->CurrentTimeSeconds(), paused_time);
+
+ // Clearing the preview resumes sampling.
+ textarea->SetSuggestedValue(String());
+ UpdateAllLifecyclePhasesForTest();
+ timeline->UpdateSnapshotForTesting();
+ EXPECT_TRUE(timeline->IsActive());
+ EXPECT_NE(timeline->CurrentTimeSeconds(), paused_time);
+}
+
+TEST_F(ScrollTimelineTest, AutofillPreviewBlocksPseudoElementScroller) {
+ // A <select> is one of the few form controls allowed to generate
+ // pseudo-elements, so its scroller may be a pseudo-element rather than the
+ // control itself. See CanHaveGeneratedChildren(). A timeline first sampled
+ // during a preview has no earlier value to retain, so it stays inactive.
+ SetBodyInnerHTML(R"HTML(
+ <style>
+ #select::before {
+ content: 'a\A b\A c\A d\A e\A f\A g\A h';
+ white-space: pre;
+ display: block;
+ overflow: scroll;
+ width: 50px;
+ height: 20px;
+ }
+ </style>
+ <select id='select' size='4'>
+ <option value='one'>one</option>
+ <option value='two'>two</option>
+ </select>
+ )HTML");
+
+ auto* select = To<HTMLSelectElement>(GetElementById("select"));
+ select->SetSuggestedValue("two");
+ UpdateAllLifecyclePhasesForTest();
+ ASSERT_TRUE(select->IsPreviewed());
+
+ PseudoElement* before = select->GetPseudoElement(kPseudoIdBefore);
+ ASSERT_TRUE(before);
+ ASSERT_TRUE(before->GetLayoutBoxForScrolling());
+
+ auto* timeline =
+ MakeGarbageCollected<TestScrollTimeline>(&GetDocument(), before);
+ timeline->UpdateSnapshotForTesting();
+
+ EXPECT_FALSE(timeline->IsActive());
+}
+
} // namespace blink
Original Bug Report
Information Leak: ScrollTimeline bypasses Autofill preview masking in <textarea>
Flapjack, 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 Timelines and the ScrollTimeline JS API bypass the masking of Autofill previews in textarea elements. By retrieving raw scroll offsets directly from the layout, an attacker can leak the length and structure of sensitive Autofill data during the preview phase.
Affected files:
third_party/blink/renderer/core/animation/scroll_timeline.cc
Estimated timestamp from git blame: 2022-06-27
Summary
A high-precision information leak exists in Blink’s animation system where CSS Scroll Timelines (and the JavaScript ScrollTimeline API) can be used to bypass the masking of Autofill previews (SuggestedValue) in <textarea> elements. This allows an attacker to potentially extract the length and structure of sensitive PII (Personally Identifiable Information) before the user has submitted the form.
Description
To protect sensitive data, Chromium masks DOM properties like scrollTop, scrollLeft, scrollWidth, and scrollHeight in HTMLTextAreaElement when an Autofill preview is displayed. This is intended to prevent malicious sites from calculating the dimensions or content of the suggested value (the preview text that appears before a user accepts an Autofill suggestion).
However, the ScrollTimeline implementation accesses the raw, unmasked layout state, completely bypassing the DOM-level protections implemented in the element’s getters.
Potential Exploitation Steps
Note: These are theoretical steps, as we have not executed a working proof-of-concept.
- An attacker creates a form designed to trigger an Autofill preview containing sensitive data.
- The attacker applies
animation-timeline: scroll(self)to the<textarea>element, attaching a CSS animation driven by the scroll position. - When the user triggers the Autofill preview, the browser inserts the sensitive text into a placeholder element inside the UserAgent Shadow DOM and masks the standard scroll getters in
HTMLTextAreaElement. - The attacker uses JavaScript to set a known scroll offset, e.g.,
textarea.scrollTop = 1. ThesetScrollTopfunction is not masked and applies this 1px offset directly to the underlyingPaintLayerScrollableArea. - The Scroll Timeline updates its state via
ScrollTimeline::ComputeTimelineState(). It retrieves the unmasked maximum scroll dimensions directly from the layout (scrollable_area->MaximumScrollOffset() - scrollable_area->MinimumScrollOffset()) and calculates the animation progress. - The attacker queries the animation’s progress via
animation.currentTime. ScrollSnapshotTimeline::ConvertTimeToProgress()returns the progress as a precise percentage (e.g.,(1 / max_offset) * 100).- The attacker mathematically reverses this percentage to discover the exact, unmasked
max_offset(and thus the exact dimensions of the preview text). - By manipulating CSS properties like
letter-spacingor using custom fonts and repeating the measurement, the attacker can iteratively deduce the exact characters of the sensitive Autofill data.
Suggested Fix
Ensure that Scroll Timelines respect the same privacy boundaries as the DOM. ScrollTimeline::ComputeTimelineState() and related functions should check if the target element is in a preview state (e.g., !SuggestedValue().empty()) and, if so, either disable the timeline or use the masked, fake layout dimensions instead of querying the raw PaintLayerScrollableArea.
Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0
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.