CVE-2026-2317
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchthird_party/blink/renderer/core/animation/keyframe_effect.cc |
modified | |
ifthird_party/blink/renderer/core/animation/keyframe_effect.cc |
modified |
Files Changed
third_party/blink/renderer/core/animation/keyframe_effect.ccthird_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
Patch
From 18889dd338aac93fa4f9b375744ff1688c307a36 Mon Sep 17 00:00:00 2001
From: Kevin Ellis <kevers@google.com>
Date: Fri, 12 Dec 2025 09:46:51 -0800
Subject: [PATCH] Prevent exposing UA shadow-DOM in KeyframeEffect constructor
Bug: 464173573
Change-Id: I241cf3e1d22e95a694590d207018c2503ad65166
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7247696
Reviewed-by: Vladimir Levin <vmpstr@chromium.org>
Commit-Queue: Kevin Ellis <kevers@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1558126}
---
diff --git a/third_party/blink/renderer/core/animation/keyframe_effect.cc b/third_party/blink/renderer/core/animation/keyframe_effect.cc
index 0639626a..f32cba57 100644
--- a/third_party/blink/renderer/core/animation/keyframe_effect.cc
+++ b/third_party/blink/renderer/core/animation/keyframe_effect.cc
@@ -48,6 +48,7 @@
#include "third_party/blink/renderer/core/css/resolver/style_resolver.h"
#include "third_party/blink/renderer/core/dom/element.h"
#include "third_party/blink/renderer/core/dom/pseudo_element.h"
+#include "third_party/blink/renderer/core/dom/shadow_root.h"
#include "third_party/blink/renderer/core/frame/web_feature.h"
#include "third_party/blink/renderer/core/paint/paint_layer.h"
#include "third_party/blink/renderer/core/style/computed_style.h"
@@ -63,26 +64,69 @@
namespace {
-// Verifies that a pseudo-element selector lexes and canonicalizes legacy forms
-bool ValidateAndCanonicalizePseudo(String& selector) {
- if (selector.IsNull()) {
- return true;
- } else if (selector.StartsWith("::")) {
- return true;
- } else if (selector == ":before") {
- selector = "::before";
- return true;
- } else if (selector == ":after") {
- selector = "::after";
- return true;
- } else if (selector == ":first-letter") {
- selector = "::first-letter";
- return true;
- } else if (selector == ":first-line") {
- selector = "::first-line";
+bool ValidatePseudoElement(String& pseudo, ExceptionState& exception_state) {
+ // https://www.w3.org/TR/web-animations-1/#dom-keyframeeffect-pseudoelement
+ if (pseudo.IsNull()) {
return true;
}
- return false;
+
+ AtomicString pseudo_argument = g_null_atom;
+ PseudoId pseudo_id = pseudo.StartsWith(":")
+ ? CSSSelectorParser::ParsePseudoElement(
+ pseudo, /*parent=*/nullptr, pseudo_argument)
+ : kPseudoIdInvalid;
+
+ switch (pseudo_id) {
+ case kPseudoIdInvalid:
+ case kPseudoIdNone: {
+ StringBuilder sb;
+ sb.Append(pseudo);
+ sb.Append(" is a syntactically invalid pseudo-element");
+ exception_state.ThrowDOMException(DOMExceptionCode::kSyntaxError,
+ sb.ToString());
+ return false;
+ }
+
+ // From the spec:
+ // Syntactically invalid pseudo-elements as well as pseudo-elements for
+ // which the user agent has no usable level of support are both deemed
+ // invalid.
+ // TODO(kevers): Are there any pseudos that belong in this second bucket?
+ // Currently input::placeholder is not animated on any of the major web
+ // platforms, though handled inconsistently. Failure to animate
+ // ::placeholder seems like a bug rather than a technical limitation.
+
+ default:
+ // Convert to canonical form.
+ if (!pseudo.StartsWith("::")) {
+ StringBuilder sb;
+ sb.Append(":");
+ sb.Append(pseudo);
+ pseudo = sb.ToString();
+ }
+ pseudo = pseudo.LowerASCII();
+ return true;
+ }
+}
+
+Element* ResolveTargetFromEffectTarget(Element* effect_target) {
+ if (!effect_target) {
+ return nullptr;
+ }
+
+ if (PseudoElement* pseudo = DynamicTo<PseudoElement>(effect_target)) {
+ return &pseudo->UltimateOriginatingElement();
+ }
+
+ // A pseudo-element for a part-like piece (e.g. input placeholder) might have
+ // previously resolved to an element inside UA-shadow DOM. In this case, the
+ // host is effect target.
+ if (effect_target->IsInUserAgentShadowRoot()) {
+ ShadowRoot* shadow_root = effect_target->ContainingShadowRoot();
+ return &shadow_root->host();
+ }
+
+ return effect_target;
}
enum class KeyframeOrderStrategy { kSpecifiedOrdering, kCssKeyframeOrdering };
@@ -148,14 +192,9 @@
auto* effect_options = options->GetAsKeyframeEffectOptions();
composite = EffectModel::EnumToCompositeOperation(
effect_options->composite().AsEnum());
- if (!effect_options->pseudoElement().empty()) {
+ if (!effect_options->pseudoElement().IsNull()) {
pseudo = effect_options->pseudoElement();
- if (!ValidateAndCanonicalizePseudo(pseudo)) {
- // TODO(gtsteel): update when
- // https://github.com/w3c/csswg-drafts/issues/4586 resolves
- exception_state.ThrowDOMException(
- DOMExceptionCode::kSyntaxError,
- "A valid pseudo-selector must be null or start with ::.");
+ if (!ValidatePseudoElement(pseudo, exception_state)) {
return nullptr;
}
}
@@ -187,6 +226,14 @@
pseudo, element, pseudo_argument);
effect->effect_target_ =
element->GetStyledPseudoElement(pseudo_id, pseudo_argument);
+ if (effect->effect_target_) {
+ DCHECK_EQ(ResolveTargetFromEffectTarget(effect->effect_target_),
+ element);
+ }
+ // TODO(crbug.com/468323247): GetStyledPseudoElement can return null even
+ // though the pseudo name is valid. Resolve how to handle this case. An
+ // example is setting up a KeyframeEffect on element::after before setting
+ // the content property on the pseudo.
}
}
return effect;
@@ -209,9 +256,18 @@
ExceptionState& exception_state) {
Timing new_timing = source->SpecifiedTiming();
KeyframeEffectModelBase* model = source->Model()->Clone();
- return MakeGarbageCollected<KeyframeEffect>(source->EffectTarget(), model,
- new_timing, source->GetPriority(),
- source->GetEventDelegate());
+ // As we already have both target and EffectTarget, we can short-circuit
+ // the conversion.
+ KeyframeEffect* clone = MakeGarbageCollected<KeyframeEffect>(
+ source->target(), model, new_timing, source->GetPriority(),
+ source->GetEventDelegate());
+ clone->effect_target_ = source->EffectTarget();
+ clone->setPseudoElement(source->pseudoElement(), exception_state);
+ if (source->EffectTarget()) {
+ DCHECK_EQ(ResolveTargetFromEffectTarget(source->EffectTarget()),
+ source->target());
+ }
+ return clone;
}
KeyframeEffect::KeyframeEffect(Element* target,
@@ -257,16 +313,10 @@
void KeyframeEffect::setPseudoElement(String pseudo,
ExceptionState& exception_state) {
- if (ValidateAndCanonicalizePseudo(pseudo)) {
+ if (ValidatePseudoElement(pseudo, exception_state)) {
target_pseudo_ = pseudo;
- } else {
- exception_state.ThrowDOMException(
- DOMExceptionCode::kSyntaxError,
- "A valid pseudo-selector must be null or start with ::.");
- return;
+ RefreshTarget();
}
-
- RefreshTarget();
}
void KeyframeEffect::RefreshTarget() {
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
deleted file mode 100644
index 73e2f5f..0000000
--- a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-This is a testharness.js-based test.
-Found 2 FAIL, 0 TIMEOUT, 0 NOTRUN.
-[FAIL] animate() with a non-null invalid pseudoElement '' throws a SyntaxError
- assert_throws_dom: function "() => {\n div.animate(null, {pseudoElement: pseudo});\n }" did not throw
-[FAIL] animate() with a non-null invalid pseudoElement '::abc' throws a SyntaxError
- assert_throws_dom: function "() => {\n div.animate(null, {pseudoElement: pseudo});\n }" did not throw
-Harness: the test ran to completion.
-
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
deleted file mode 100644
index 73e2f5f..0000000
--- a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-This is a testharness.js-based test.
-Found 2 FAIL, 0 TIMEOUT, 0 NOTRUN.
-[FAIL] animate() with a non-null invalid pseudoElement '' throws a SyntaxError
- assert_throws_dom: function "() => {\n div.animate(null, {pseudoElement: pseudo});\n }" did not throw
-[FAIL] animate() with a non-null invalid pseudoElement '::abc' throws a SyntaxError
- assert_throws_dom: function "() => {\n div.animate(null, {pseudoElement: pseudo});\n }" did not throw
-Harness: the test ran to completion.
-
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/copy-constructor.html b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/copy-constructor.html
index e3bc0db0..3126f110 100644
--- a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/copy-constructor.html
+++ b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/copy-constructor.html
@@ -20,6 +20,24 @@
}, 'Copied KeyframeEffect has the same target');
test(t => {
+ const target = createElement(t, 'input');
+ target.setAttribute('type', 'input');
+ target.setAttribute('placeholder', "hello");
+ let effect = undefined;
+ try {
+ // Pseudo-elements for which the user agent has no usable level of support
+ // are deemed invalid. Test passes if no effect is generated.
+ effect = new KeyframeEffect(target, [], { pseudoElement: '::placeholder' });
+ } catch (e) {}
+ if (effect) {
+ // If an effect is generated (UA supports the pseudo), then the copy
+ // constructor is to retain the correct target.
+ const copy = new KeyframeEffect(effect);
+ assert_equals(copy.target, target);
+ }
+}, 'Copied KeyframeEffect does not expose UA-shadow DOM');
+
+test(t => {
const effect =
new KeyframeEffect(null,
[ { marginLeft: '0px' },
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/target-expected.txt b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/target-expected.txt
index 49e7547..0ccf8eb 100644
--- a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/target-expected.txt
+++ b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/target-expected.txt
@@ -11,9 +11,5 @@
assert_equals: Value of 2nd element (currently targeted) after changing the effect target expected "50px" but got "20px"
[FAIL] Change target from a non-existing to a different non-existing pseudo-element by setting pseudoElement.
assert_equals: Value of 2nd element (currently targeted) after changing the effect target expected "50px" but got "20px"
-[FAIL] Changing pseudoElement to a non-null invalid pseudo-selector '::abc' throws a SyntaxError
- assert_throws_dom: function "() => effect.pseudoElement = pseudo" did not throw
-[FAIL] Changing pseudoElement to ::placeHOLDER works
- assert_equals: expected "::placeholder" but got "::placeHOLDER"
Harness: the test ran to completion.
Original Bug Report
KeyframeEffect constructor leaks UA shadow root.
Security Bug
Important: Please do not change the component of this bug manually.
Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md
Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs
Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp
NOTE: Security bugs are normally made public once a fix has been widely deployed.
VULNERABILITY DETAILS
When a Keyframe is constructed with the pseudoElement option, animations are applied to the corresponding pseudo-element, but the target property still refers to the originating element:
effect = new KeyframeEffect(element, [], {pseudoElement: "::placeholder"});
effect.target == inputElement;
But when cloning from another KeframeEffect, the element the animations are applied to is used as the target too:
effect = new KeyframeEffect(effect);
effect.target != inputElement;
```.
This element may be a pseudo element or a "styled" pseudo element residing in a target element's shadow tree. The placeholder attribute rendered within an <input> element's shadow tree is a an example of an element that can be animated but should not be accessible.
Attached example observers and logs changes to an input's ::placeholder pseudo element. If the user has an saved address in chrome, hovering over the suggested address will disclose it without confirmation.
Many other internal elements may be accessed this way. They can be identified in blink source by use of the `Element::SetShadowPseudoId` method.
VERSION
Chrome Version: Version 144.0.7533.0 (Developer Build) (64-bit)
Operating System: Linux
REPRODUCTION CASE
Visit chrome://settings/addresses and add an address with at least a street address
Open poc.html in chrome served from an HTTP server.
Click on or tab to the input under the Address header.
Hover over suggested address or press down arrow.
Observe the street address is printed to the output.
CREDIT INFORMATION
Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited?
Reporter credit: Brendan Draper