Medium chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Animation
DescriptionInformation leak in Animation
ComponentAnimation
Sub-componentBlink>Animation
Bug ClassInfo Disclosure
Tracker517708426
Fix commit1434759f02eb (chromium/src) +503/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
InternalVisitedColor
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedColumnRuleColor
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedFill
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedFloodColor
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedLightingColor
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedOutlineColor
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedStopColor
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedStroke
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedTextDecorationColor
third_party/blink/renderer/core/style/computed_style.h
modified
InternalVisitedTextEmphasisColor
third_party/blink/renderer/core/style/computed_style.h
modified
TEST_P
third_party/blink/renderer/core/animation/css/css_animations_test.cc
modified

Files Changed

  • third_party/blink/web_tests/external/wpt/css/css-color/color-svg-visited-ref.html
  • third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
  • third_party/blink/web_tests/external/wpt/css/css-animations/svg-color-visited-currentcolor.html
  • third_party/blink/renderer/core/animation/color_property_functions.cc
  • third_party/blink/public/mojom/use_counter/metrics/css_property_id.mojom
  • third_party/blink/renderer/core/css/css_property_equality.cc
  • third_party/blink/web_tests/external/wpt/css/css-color/color-svg-visited.html
  • third_party/blink/renderer/core/css/css_properties.json5
  • third_party/blink/renderer/core/animation/css/css_animations_test.cc
  • third_party/blink/renderer/core/style/computed_style.h
  • third_party/blink/renderer/core/css/resolver/cascade_expansion_test.cc
  • third_party/blink/renderer/core/css/parser/css_parser_fast_paths.cc
From 1434759f02ebd315ce220436f91cb24ab5ace9ff Mon Sep 17 00:00:00 2001
From: Ari Chivukula <[email protected]>
Date: Fri, 11 Sep 2026 08:39:50 -0700
Subject: [PATCH] Prevent flood/lighting/stop-color from leaking :visited

As demonstrated in the added tests, it was possible to use
animations/transitions to extract :visited status of links via
properties readable in JavaScript. This is disallowed per:
https://drafts.csswg.org/css-pseudo/#highlight-security

This CL splits off visited properties into their own internal records,
following the pattern for all other properties in Set/GetVisitedColor.

NO_IFTTT=No enums generated for internal entries

Skip-Clang-Tidy-Checks: google-default-arguments
Fixed: 517708426
Change-Id: I02b4862843723b5d76e391d7dcf39a001ed9af63
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8373342
Reviewed-by: Kevin Ellis <[email protected]>
Commit-Queue: Ari Chivukula <[email protected]>
Auto-Submit: Ari Chivukula <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1696291}
---
diff --git a/third_party/blink/public/mojom/use_counter/metrics/css_property_id.mojom b/third_party/blink/public/mojom/use_counter/metrics/css_property_id.mojom
index 78a23d7..4ef3f7e 100644
--- a/third_party/blink/public/mojom/use_counter/metrics/css_property_id.mojom
+++ b/third_party/blink/public/mojom/use_counter/metrics/css_property_id.mojom
@@ -34,7 +34,10 @@
     kInternalVisitedColor = 0,
     kInternalVisitedColumnRuleColor = 0,
     kInternalVisitedFill = 0,
+    kInternalVisitedFloodColor = 0,
+    kInternalVisitedLightingColor = 0,
     kInternalVisitedOutlineColor = 0,
+    kInternalVisitedStopColor = 0,
     kInternalVisitedStroke = 0,
     kInternalVisitedTextDecorationColor = 0,
     kInternalVisitedTextEmphasisColor = 0,
diff --git a/third_party/blink/renderer/core/animation/color_property_functions.cc b/third_party/blink/renderer/core/animation/color_property_functions.cc
index 7cb9d60..bc44189 100644
--- a/third_party/blink/renderer/core/animation/color_property_functions.cc
+++ b/third_party/blink/renderer/core/animation/color_property_functions.cc
@@ -104,11 +104,11 @@
     case CSSPropertyID::kWebkitTextStrokeColor:
       return OptionalStyleColor(style.InternalVisitedTextStrokeColor());
     case CSSPropertyID::kFloodColor:
-      return OptionalStyleColor(style.FloodColor());
+      return OptionalStyleColor(style.InternalVisitedFloodColor());
     case CSSPropertyID::kLightingColor:
-      return OptionalStyleColor(style.LightingColor());
+      return OptionalStyleColor(style.InternalVisitedLightingColor());
     case CSSPropertyID::kStopColor:
-      return OptionalStyleColor(style.StopColor());
+      return OptionalStyleColor(style.InternalVisitedStopColor());
     case CSSPropertyID::kWebkitTapHighlightColor:
       return OptionalStyleColor(style.TapHighlightColor());
     case CSSPropertyID::kTextDecorationColor:
@@ -214,16 +214,16 @@
       builder.SetInternalVisitedColor(style_color);
       return;
     case CSSPropertyID::kFloodColor:
-      builder.SetFloodColor(style_color);
+      builder.SetInternalVisitedFloodColor(style_color);
       return;
     case CSSPropertyID::kLightingColor:
-      builder.SetLightingColor(style_color);
+      builder.SetInternalVisitedLightingColor(style_color);
       return;
     case CSSPropertyID::kOutlineColor:
       builder.SetInternalVisitedOutlineColor(style_color);
       return;
     case CSSPropertyID::kStopColor:
-      builder.SetStopColor(style_color);
+      builder.SetInternalVisitedStopColor(style_color);
       return;
     case CSSPropertyID::kTextDecorationColor:
       builder.SetInternalVisitedTextDecorationColor(style_color);
diff --git a/third_party/blink/renderer/core/css/css_properties.json5 b/third_party/blink/renderer/core/css/css_properties.json5
index a5eb81b..4dd46c2 100644
--- a/third_party/blink/renderer/core/css/css_properties.json5
+++ b/third_party/blink/renderer/core/css/css_properties.json5
@@ -10523,6 +10523,38 @@
       valid_for_highlight: true,
     },
     {
+      name: "-internal-visited-flood-color",
+      visited_property_for: "flood-color",
+      property_methods: ["ColorIncludingFallback"],
+      parse_helper: "ConsumeColor",
+      field_group: "misc->visited",
+      field_template: "external",
+      include_paths: ["third_party/blink/renderer/core/css/style_color.h"],
+      default_value: "StyleColor(Color::kBlack)",
+      type_name: "StyleColor",
+      style_builder_template: "visited_color",
+      style_builder_template_args: {
+        initial_color: "ComputedStyleInitialValues::InitialFloodColor",
+      },
+      converter: "ConvertStyleColor",
+    },
+    {
+      name: "-internal-visited-lighting-color",
+      visited_property_for: "lighting-color",
+      property_methods: ["ColorIncludingFallback"],
+      parse_helper: "ConsumeColor",
+      field_group: "misc->visited",
+      field_template: "external",
+      include_paths: ["third_party/blink/renderer/core/css/style_color.h"],
+      default_value: "StyleColor(Color::kWhite)",
+      type_name: "StyleColor",
+      style_builder_template: "visited_color",
+      style_builder_template_args: {
+        initial_color: "ComputedStyleInitialValues::InitialLightingColor",
+      },
+      converter: "ConvertStyleColor",
+    },
+    {
       name: "-internal-visited-outline-color",
       visited_property_for: "outline-color",
       property_methods: ["ParseSingleValue", "ColorIncludingFallback"],
@@ -10538,6 +10570,22 @@
       invalidate: ["border-outline-visited-color"],
     },
     {
+      name: "-internal-visited-stop-color",
+      visited_property_for: "stop-color",
+      property_methods: ["ColorIncludingFallback"],
+      parse_helper: "ConsumeColor",
+      field_group: "misc->visited",
+      field_template: "external",
+      include_paths: ["third_party/blink/renderer/core/css/style_color.h"],
+      default_value: "StyleColor(Color::kBlack)",
+      type_name: "StyleColor",
+      style_builder_template: "visited_color",
+      style_builder_template_args: {
+        initial_color: "ComputedStyleInitialValues::InitialStopColor",
+      },
+      converter: "ConvertStyleColor",
+    },
+    {
       name: "-internal-visited-stroke",
       visited_property_for: "stroke",
       property_methods: ["ColorIncludingFallback"],
diff --git a/third_party/blink/renderer/core/css/css_property_equality.cc b/third_party/blink/renderer/core/css/css_property_equality.cc
index 6d4f565..8c873fd6 100644
--- a/third_party/blink/renderer/core/css/css_property_equality.cc
+++ b/third_party/blink/renderer/core/css/css_property_equality.cc
@@ -468,7 +468,9 @@
     case CSSPropertyID::kFloat:
       return ValueEquals(a.Floating(), b.Floating());
     case CSSPropertyID::kFloodColor:
-      return ValueEquals(a.FloodColor(), b.FloodColor());
+      return ValueEquals(a.FloodColor(), b.FloodColor()) &&
+             ValueEquals(a.InternalVisitedFloodColor(),
+                         b.InternalVisitedFloodColor());
     case CSSPropertyID::kFloodOpacity:
       return ValueEquals(a.FloodOpacity(), b.FloodOpacity());
     case CSSPropertyID::kFontFamily:
@@ -637,7 +639,9 @@
     case CSSPropertyID::kLetterSpacing:
       return ValueEquals(a.ComputedLetterSpacing(), b.ComputedLetterSpacing());
     case CSSPropertyID::kLightingColor:
-      return ValueEquals(a.LightingColor(), b.LightingColor());
+      return ValueEquals(a.LightingColor(), b.LightingColor()) &&
+             ValueEquals(a.InternalVisitedLightingColor(),
+                         b.InternalVisitedLightingColor());
     case CSSPropertyID::kLineBreak:
       return ValueEquals(a.GetLineBreak(), b.GetLineBreak());
     case CSSPropertyID::kLineClamp:
@@ -836,7 +840,9 @@
     case CSSPropertyID::kSpeak:
       return ValueEquals(a.Speak(), b.Speak());
     case CSSPropertyID::kStopColor:
-      return ValueEquals(a.StopColor(), b.StopColor());
+      return ValueEquals(a.StopColor(), b.StopColor()) &&
+             ValueEquals(a.InternalVisitedStopColor(),
+                         b.InternalVisitedStopColor());
     case CSSPropertyID::kStopOpacity:
       return ValueEquals(a.StopOpacity(), b.StopOpacity());
     case CSSPropertyID::kStroke:
@@ -1436,7 +1442,10 @@
     case CSSPropertyID::kInternalVisitedColor:
     case CSSPropertyID::kInternalVisitedColumnRuleColor:
     case CSSPropertyID::kInternalVisitedFill:
+    case CSSPropertyID::kInternalVisitedFloodColor:
+    case CSSPropertyID::kInternalVisitedLightingColor:
     case CSSPropertyID::kInternalVisitedOutlineColor:
+    case CSSPropertyID::kInternalVisitedStopColor:
     case CSSPropertyID::kInternalVisitedStroke:
     case CSSPropertyID::kInternalVisitedTextDecorationColor:
     case CSSPropertyID::kInternalVisitedTextEmphasisColor:
diff --git a/third_party/blink/renderer/core/css/parser/css_parser_fast_paths.cc b/third_party/blink/renderer/core/css/parser/css_parser_fast_paths.cc
index aea10a0..eeb84f2 100644
--- a/third_party/blink/renderer/core/css/parser/css_parser_fast_paths.cc
+++ b/third_party/blink/renderer/core/css/parser/css_parser_fast_paths.cc
@@ -263,7 +263,10 @@
       CSSPropertyID::kInternalVisitedBorderRightColor,
       CSSPropertyID::kInternalVisitedBorderTopColor,
       CSSPropertyID::kInternalVisitedFill,
+      CSSPropertyID::kInternalVisitedFloodColor,
+      CSSPropertyID::kInternalVisitedLightingColor,
       CSSPropertyID::kInternalVisitedOutlineColor,
+      CSSPropertyID::kInternalVisitedStopColor,
       CSSPropertyID::kInternalVisitedStroke,
       CSSPropertyID::kInternalVisitedBorderBlockEndColor,
       CSSPropertyID::kInternalVisitedBorderBlockStartColor,
diff --git a/third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc b/third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
index 552efea..6f3edfc 100644
--- a/third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
+++ b/third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
@@ -5623,6 +5623,45 @@
                                     style.UsedColorScheme(), is_current_color);
 }
 
+const blink::Color InternalVisitedFloodColor::ColorIncludingFallback(
+    bool visited_link,
+    const ComputedStyle& style,
+    bool* is_current_color) const {
+  DCHECK(visited_link);
+  const StyleColor& flood_color = style.InternalVisitedFloodColor();
+  if (style.ShouldForceColor(flood_color)) {
+    return style.GetInternalForcedCurrentColor(is_current_color);
+  }
+  return flood_color.Resolve(style.GetInternalVisitedCurrentColor(),
+                             style.UsedColorScheme(), is_current_color);
+}
+
+const blink::Color InternalVisitedLightingColor::ColorIncludingFallback(
+    bool visited_link,
+    const ComputedStyle& style,
+    bool* is_current_color) const {
+  DCHECK(visited_link);
+  const StyleColor& lighting_color = style.InternalVisitedLightingColor();
+  if (style.ShouldForceColor(lighting_color)) {
+    return style.GetInternalForcedCurrentColor(is_current_color);
+  }
+  return lighting_color.Resolve(style.GetInternalVisitedCurrentColor(),
+                                style.UsedColorScheme(), is_current_color);
+}
+
+const blink::Color InternalVisitedStopColor::ColorIncludingFallback(
+    bool visited_link,
+    const ComputedStyle& style,
+    bool* is_current_color) const {
+  DCHECK(visited_link);
+  const StyleColor& stop_color = style.InternalVisitedStopColor();
+  if (style.ShouldForceColor(stop_color)) {
+    return style.GetInternalForcedCurrentColor(is_current_color);
+  }
+  return stop_color.Resolve(style.GetInternalVisitedCurrentColor(),
+                            style.UsedColorScheme(), is_current_color);
+}
+
 const CSSValue* ColumnRuleBreak::CSSValueFromComputedStyleInternal(
     const ComputedStyle& style,
     const LayoutObject*,
diff --git a/third_party/blink/renderer/core/style/computed_style.h b/third_party/blink/renderer/core/style/computed_style.h
index 5c9c21c..89fae9e 100644
--- a/third_party/blink/renderer/core/style/computed_style.h
+++ b/third_party/blink/renderer/core/style/computed_style.h
@@ -142,7 +142,10 @@
 class InternalVisitedColor;
 class InternalVisitedColumnRuleColor;
 class InternalVisitedFill;
+class InternalVisitedFloodColor;
+class InternalVisitedLightingColor;
 class InternalVisitedOutlineColor;
+class InternalVisitedStopColor;
 class InternalVisitedStroke;
 class InternalVisitedTextDecorationColor;
 class InternalVisitedTextEmphasisColor;
@@ -265,7 +268,10 @@
   friend class css_longhand::InternalVisitedColor;
   friend class css_longhand::InternalVisitedColumnRuleColor;
   friend class css_longhand::InternalVisitedFill;
+  friend class css_longhand::InternalVisitedFloodColor;
+  friend class css_longhand::InternalVisitedLightingColor;
   friend class css_longhand::InternalVisitedOutlineColor;
+  friend class css_longhand::InternalVisitedStopColor;
   friend class css_longhand::InternalVisitedStroke;
   friend class css_longhand::InternalVisitedTextDecorationColor;
   friend class css_longhand::InternalVisitedTextEmphasisColor;
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 fb6946a0..04039f8a 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
@@ -3222,6 +3222,192 @@
   EXPECT_FALSE(IsUseCounted(WebFeature::kCSSTimelineScopeAttachedMultiple));
 }
 
+TEST_P(CSSAnimationsTest, SVGColorAnimationVisitedCurrentColor) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      a:link { color: rgb(0, 255, 0); }
+      a:visited { color: rgb(255, 0, 0); }
+
+      @keyframes anim_flood { from, to { flood-color: currentColor; } }
+      @keyframes anim_lighting { from, to { lighting-color: currentColor; } }
+      @keyframes anim_stop { from, to { stop-color: currentColor; } }
+
+      .probe_flood { color: inherit; animation: anim_flood 1s paused; }
+      .probe_lighting { color: inherit; animation: anim_lighting 1s paused; }
+      .probe_stop { color: inherit; animation: anim_stop 1s paused; }
+    </style>
+    <a id="visited" href="">
+      <svg>
+        <filter>
+          <feFlood id="flood_visited" class="probe_flood"></feFlood>
+          <feDiffuseLighting id="lighting_visited" class="probe_lighting">
+            <fePointLight x="0" y="0" z="0"></fePointLight>
+          </feDiffuseLighting>
+        </filter>
+        <linearGradient>
+          <stop id="stop_visited" class="probe_stop"></stop>
+        </linearGradient>
+      </svg>
+    </a>
+    <a id="unvisited" href="http://unvisited.example.com">
+      <svg>
+        <filter>
+          <feFlood id="flood_unvisited" class="probe_flood"></feFlood>
+          <feDiffuseLighting id="lighting_unvisited" class="probe_lighting">
+            <fePointLight x="0" y="0" z="0"></fePointLight>
+          </feDiffuseLighting>
+        </filter>
+        <linearGradient>
+          <stop id="stop_unvisited" class="probe_stop"></stop>
+        </linearGradient>
+      </svg>
+    </a>
+  )HTML");
+
+  UpdateAllLifecyclePhasesForTest();
+
+  auto* window = GetDocument().domWindow();
+  Element* flood_visited =
+      GetDocument().getElementById(AtomicString("flood_visited"));
+  Element* flood_unvisited =
+      GetDocument().getElementById(AtomicString("flood_unvisited"));
+  Element* lighting_visited =
+      GetDocument().getElementById(AtomicString("lighting_visited"));
+  Element* lighting_unvisited =
+      GetDocument().getElementById(AtomicString("lighting_unvisited"));
+  Element* stop_visited =
+      GetDocument().getElementById(AtomicString("stop_visited"));
+  Element* stop_unvisited =
+      GetDocument().getElementById(AtomicString("stop_unvisited"));
+
+  EXPECT_EQ(
+      window->getComputedStyle(flood_unvisited)
+          ->getPropertyValue("flood-color"),
+      window->getComputedStyle(flood_visited)->getPropertyValue("flood-color"));
+  EXPECT_EQ(
+      window->getComputedStyle(flood_visited)->getPropertyValue("flood-color"),
+      "rgb(0, 255, 0)");
+
+  EXPECT_EQ(window->getComputedStyle(lighting_unvisited)
+                ->getPropertyValue("lighting-color"),
+            window->getComputedStyle(lighting_visited)
+                ->getPropertyValue("lighting-color"));
+  EXPECT_EQ(window->getComputedStyle(lighting_visited)
+                ->getPropertyValue("lighting-color"),
+            "rgb(0, 255, 0)");
+
+  EXPECT_EQ(
+      window->getComputedStyle(stop_unvisited)->getPropertyValue("stop-color"),
+      window->getComputedStyle(stop_visited)->getPropertyValue("stop-color"));
+  EXPECT_EQ(
+      window->getComputedStyle(stop_visited)->getPropertyValue("stop-color"),
+      "rgb(0, 255, 0)");
+}
+
+TEST_P(CSSAnimationsTest, SVGColorTransitionVisitedCurrentColor) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      a:link { color: rgb(0, 255, 0); }
+      a:visited { color: rgb(255, 0, 0); }
+
+      .probe_flood {
+        color: inherit;
+        flood-color: rgb(0, 0, 255);
+        transition: flood-color 10s steps(2, start);
+      }
+      .probe_lighting {
+        color: inherit;
+        lighting-color: rgb(0, 0, 255);
+        transition: lighting-color 10s steps(2, start);
+      }
+      .probe_stop {
+        color: inherit;
+        stop-color: rgb(0, 0, 255);
+        transition: stop-color 10s steps(2, start);
+      }
+      .transition_active {
+        flood-color: currentColor;
+        lighting-color: currentColor;
+        stop-color: currentColor;
+      }
+    </style>
... (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 fb6946a0..04039f8a 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
@@ -3222,6 +3222,192 @@
   EXPECT_FALSE(IsUseCounted(WebFeature::kCSSTimelineScopeAttachedMultiple));
 }
 
+TEST_P(CSSAnimationsTest, SVGColorAnimationVisitedCurrentColor) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      a:link { color: rgb(0, 255, 0); }
+      a:visited { color: rgb(255, 0, 0); }
+
+      @keyframes anim_flood { from, to { flood-color: currentColor; } }
+      @keyframes anim_lighting { from, to { lighting-color: currentColor; } }
+      @keyframes anim_stop { from, to { stop-color: currentColor; } }
+
+      .probe_flood { color: inherit; animation: anim_flood 1s paused; }
+      .probe_lighting { color: inherit; animation: anim_lighting 1s paused; }
+      .probe_stop { color: inherit; animation: anim_stop 1s paused; }
+    </style>
+    <a id="visited" href="">
+      <svg>
+        <filter>
+          <feFlood id="flood_visited" class="probe_flood"></feFlood>
+          <feDiffuseLighting id="lighting_visited" class="probe_lighting">
+            <fePointLight x="0" y="0" z="0"></fePointLight>
+          </feDiffuseLighting>
+        </filter>
+        <linearGradient>
+          <stop id="stop_visited" class="probe_stop"></stop>
+        </linearGradient>
+      </svg>
+    </a>
+    <a id="unvisited" href="http://unvisited.example.com">
+      <svg>
+        <filter>
+          <feFlood id="flood_unvisited" class="probe_flood"></feFlood>
+          <feDiffuseLighting id="lighting_unvisited" class="probe_lighting">
+            <fePointLight x="0" y="0" z="0"></fePointLight>
+          </feDiffuseLighting>
+        </filter>
+        <linearGradient>
+          <stop id="stop_unvisited" class="probe_stop"></stop>
+        </linearGradient>
+      </svg>
+    </a>
+  )HTML");
+
+  UpdateAllLifecyclePhasesForTest();
+
+  auto* window = GetDocument().domWindow();
+  Element* flood_visited =
+      GetDocument().getElementById(AtomicString("flood_visited"));
+  Element* flood_unvisited =
+      GetDocument().getElementById(AtomicString("flood_unvisited"));
+  Element* lighting_visited =
+      GetDocument().getElementById(AtomicString("lighting_visited"));
+  Element* lighting_unvisited =
+      GetDocument().getElementById(AtomicString("lighting_unvisited"));
+  Element* stop_visited =
+      GetDocument().getElementById(AtomicString("stop_visited"));
+  Element* stop_unvisited =
+      GetDocument().getElementById(AtomicString("stop_unvisited"));
+
+  EXPECT_EQ(
+      window->getComputedStyle(flood_unvisited)
+          ->getPropertyValue("flood-color"),
+      window->getComputedStyle(flood_visited)->getPropertyValue("flood-color"));
+  EXPECT_EQ(
+      window->getComputedStyle(flood_visited)->getPropertyValue("flood-color"),
+      "rgb(0, 255, 0)");
+
+  EXPECT_EQ(window->getComputedStyle(lighting_unvisited)
+                ->getPropertyValue("lighting-color"),
+            window->getComputedStyle(lighting_visited)
+                ->getPropertyValue("lighting-color"));
+  EXPECT_EQ(window->getComputedStyle(lighting_visited)
+                ->getPropertyValue("lighting-color"),
+            "rgb(0, 255, 0)");
+
+  EXPECT_EQ(
+      window->getComputedStyle(stop_unvisited)->getPropertyValue("stop-color"),
+      window->getComputedStyle(stop_visited)->getPropertyValue("stop-color"));
+  EXPECT_EQ(
+      window->getComputedStyle(stop_visited)->getPropertyValue("stop-color"),
+      "rgb(0, 255, 0)");
+}
+
+TEST_P(CSSAnimationsTest, SVGColorTransitionVisitedCurrentColor) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+      a:link { color: rgb(0, 255, 0); }
+      a:visited { color: rgb(255, 0, 0); }
+
+      .probe_flood {
+        color: inherit;
+        flood-color: rgb(0, 0, 255);
+        transition: flood-color 10s steps(2, start);
+      }
+      .probe_lighting {
+        color: inherit;
+        lighting-color: rgb(0, 0, 255);
+        transition: lighting-color 10s steps(2, start);
+      }
+      .probe_stop {
+        color: inherit;
+        stop-color: rgb(0, 0, 255);
+        transition: stop-color 10s steps(2, start);
+      }
+      .transition_active {
+        flood-color: currentColor;
+        lighting-color: currentColor;
+        stop-color: currentColor;
+      }
+    </style>
+    <a id="visited" href="">
+      <svg>
+        <filter>
+          <feFlood class="probe_flood" id="flood_visited"></feFlood>
+          <feDiffuseLighting class="probe_lighting" id="lighting_visited">
+            <fePointLight x="0" y="0" z="0"></fePointLight>
+          </feDiffuseLighting>
+        </filter>
+        <linearGradient>
+          <stop class="probe_stop" id="stop_visited"></stop>
+        </linearGradient>
+      </svg>
+    </a>
+    <a id="unvisited" href="http://unvisited.example.com">
+      <svg>
+        <filter>
+          <feFlood class="probe_flood" id="flood_unvisited"></feFlood>
+          <feDiffuseLighting class="probe_lighting" id="lighting_unvisited">
+            <fePointLight x="0" y="0" z="0"></fePointLight>
+          </feDiffuseLighting>
+        </filter>
+        <linearGradient>
+          <stop class="probe_stop" id="stop_unvisited"></stop>
+        </linearGradient>
+      </svg>
+    </a>
+  )HTML");
+
+  UpdateAllLifecyclePhasesForTest();
+
+  Element* flood_visited =
+      GetDocument().getElementById(AtomicString("flood_visited"));
+  Element* flood_unvisited =
+      GetDocument().getElementById(AtomicString("flood_unvisited"));
+  Element* lighting_visited =
+      GetDocument().getElementById(AtomicString("lighting_visited"));
+  Element* lighting_unvisited =
+      GetDocument().getElementById(AtomicString("lighting_unvisited"));
+  Element* stop_visited =
+      GetDocument().getElementById(AtomicString("stop_visited"));
+  Element* stop_unvisited =
+      GetDocument().getElementById(AtomicString("stop_unvisited"));
+
+  flood_visited->classList().Add(AtomicString("transition_active"));
+  flood_unvisited->classList().Add(AtomicString("transition_active"));
+  lighting_visited->classList().Add(AtomicString("transition_active"));
+  lighting_unvisited->classList().Add(AtomicString("transition_active"));
+  stop_visited->classList().Add(AtomicString("transition_active"));
+  stop_unvisited->classList().Add(AtomicString("transition_active"));
+
+  UpdateAllLifecyclePhasesForTest();
+
+  auto* window = GetDocument().domWindow();
+  EXPECT_EQ(
+      window->getComputedStyle(flood_unvisited)
+          ->getPropertyValue("flood-color"),
+      window->getComputedStyle(flood_visited)->getPropertyValue("flood-color"));
+  EXPECT_EQ(
+      window->getComputedStyle(flood_visited)->getPropertyValue("flood-color"),
+      "rgb(0, 128, 128)");
+
+  EXPECT_EQ(window->getComputedStyle(lighting_unvisited)
+                ->getPropertyValue("lighting-color"),
+            window->getComputedStyle(lighting_visited)
+                ->getPropertyValue("lighting-color"));
+  EXPECT_EQ(window->getComputedStyle(lighting_visited)
+                ->getPropertyValue("lighting-color"),
+            "rgb(0, 128, 128)");
+
+  EXPECT_EQ(
+      window->getComputedStyle(stop_unvisited)->getPropertyValue("stop-color"),
+      window->getComputedStyle(stop_visited)->getPropertyValue("stop-color"));
+  EXPECT_EQ(
+      window->getComputedStyle(stop_visited)->getPropertyValue("stop-color"),
+      "rgb(0, 128, 128)");
+}
+
 class AnimatedSourceTest : public RenderingTest {
  public:
   AnimatedSourceTest()
diff --git a/third_party/blink/renderer/core/css/resolver/cascade_expansion_test.cc b/third_party/blink/renderer/core/css/resolver/cascade_expansion_test.cc
index 9de4e570..45353e8 100644
--- a/third_party/blink/renderer/core/css/resolver/cascade_expansion_test.cc
+++ b/third_party/blink/renderer/core/css/resolver/cascade_expansion_test.cc
@@ -37,7 +37,10 @@
     CSSPropertyID::kInternalVisitedCaretColor,
     CSSPropertyID::kInternalVisitedColumnRuleColor,
     CSSPropertyID::kInternalVisitedFill,
+    CSSPropertyID::kInternalVisitedFloodColor,
+    CSSPropertyID::kInternalVisitedLightingColor,
     CSSPropertyID::kInternalVisitedOutlineColor,
+    CSSPropertyID::kInternalVisitedStopColor,
     CSSPropertyID::kInternalVisitedStroke,
     CSSPropertyID::kInternalVisitedTextDecorationColor,
     CSSPropertyID::kInternalVisitedTextEmphasisColor,
diff --git a/third_party/blink/web_tests/external/wpt/css/css-animations/svg-color-visited-currentcolor.html b/third_party/blink/web_tests/external/wpt/css/css-animations/svg-color-visited-currentcolor.html
new file mode 100644
index 0000000..c6cd78f
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-animations/svg-color-visited-currentcolor.html
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>SVG color property animations with currentColor on visited links</title>
+<link rel="help" href="https://drafts.csswg.org/css-animations-1/">
+<link rel="help" href="https://drafts.csswg.org/selectors-4/#link">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+  a:link {
+    color: rgb(0, 255, 0);
+  }
+  a:visited {
+    color: rgb(255, 0, 0);
+  }
+
+  @keyframes anim-flood {
+    from, to { flood-color: currentColor; }
+  }
+  @keyframes anim-lighting {
+    from, to { lighting-color: currentColor; }
+  }
+  @keyframes anim-stop {
+    from, to { stop-color: currentColor; }
+  }
+
+  .probe-flood {
+    color: inherit;
+    animation: anim-flood 1s paused;
+  }
+  .probe-lighting {
+    color: inherit;
+    animation: anim-lighting 1s paused;
+  }
+  .probe-stop {
+    color: inherit;
+    animation: anim-stop 1s paused;
+  }
+</style>
+<a id="visited" href="">
+  <svg>
+    <filter>
+      <feFlood id="flood-visited" class="probe-flood"></feFlood>
+      <feDiffuseLighting id="lighting-visited" class="probe-lighting">
+        <fePointLight x="0" y="0" z="0"></fePointLight>
+      </feDiffuseLighting>
+    </filter>
+    <linearGradient>
+      <stop id="stop-visited" class="probe-stop"></stop>
+    </linearGradient>
+  </svg>
+</a>
+<a id="unvisited" href="http://unvisited.example.com">
+  <svg>
+    <filter>
+      <feFlood id="flood-unvisited" class="probe-flood"></feFlood>
+      <feDiffuseLighting id="lighting-unvisited" class="probe-lighting">
+        <fePointLight x="0" y="0" z="0"></fePointLight>
+      </feDiffuseLighting>
+    </filter>
+    <linearGradient>
+      <stop id="stop-unvisited" class="probe-stop"></stop>
+    </linearGradient>
+  </svg>
+</a>
+<script>
+test(() => {
+  const floodVisited = document.getElementById('flood-visited');
+  const floodUnvisited = document.getElementById('flood-unvisited');
+  assert_equals(getComputedStyle(floodUnvisited).floodColor, 'rgb(0, 255, 0)');
+  assert_equals(getComputedStyle(floodVisited).floodColor, 'rgb(0, 255, 0)');
+}, 'flood-color currentColor animation resolves to unvisited link color');
+
+test(() => {
+  const lightingVisited = document.getElementById('lighting-visited');
+  const lightingUnvisited = document.getElementById('lighting-unvisited');
+  assert_equals(getComputedStyle(lightingUnvisited).lightingColor, 'rgb(0, 255, 0)');
+  assert_equals(getComputedStyle(lightingVisited).lightingColor, 'rgb(0, 255, 0)');
+}, 'lighting-color currentColor animation resolves to unvisited link color');
+
+test(() => {
+  const stopVisited = document.getElementById('stop-visited');
+  const stopUnvisited = document.getElementById('stop-unvisited');
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

CSS :visited history leak via SVG color property animations using currentColor

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 history disclosure vulnerability exists in Blink’s CSS animation implementation, allowing websites to synchronously detect whether a user has visited a specific URL. By animating SVG color properties inside an anchor element using currentColor, the resolved :visited style can overwrite the shared unvisited style slot in ComputedStyle. As a result, the user’s actual visited state can be synchronously read back via getComputedStyle(), bypassing standard :visited privacy restrictions.

Affected files:

  • third_party/blink/renderer/core/animation/color_property_functions.cc
  • third_party/blink/renderer/core/animation/css_color_interpolation_type.cc
  • third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc

Estimated timestamp from git blame: 2026-02-23

Potential History Leak via SVG Color Animations

A potential history disclosure vulnerability has been identified in Blink’s styling and animation engine. The flaw allows an untrusted webpage to synchronously determine whether a user has visited a specific URL by setting up a CSS keyframe animation or transition with SVG-specific color properties (flood-color, lighting-color, or stop-color) resolving currentColor inside a link.

Note: These steps and analysis are potential and suggested, based on meticulous static code tracing, as our tooling does not have the capability to run dynamic proof-of-concept exploits.


Root Cause Analysis

1. Lack of Dedicated :visited Slots

In third_party/blink/renderer/core/animation/color_property_functions.cc, the helper function ColorPropertyFunctions::SetVisitedColor handles setting colors for CSS properties under :visited states. For kFloodColor, kLightingColor, and kStopColor, it writes to the exact same ComputedStyleBuilder field as SetUnvisitedColor because no dedicated -internal-visited slots exist for these SVG properties:

case CSSPropertyID::kFloodColor:    builder.SetFloodColor(style_color);    return;
case CSSPropertyID::kLightingColor: builder.SetLightingColor(style_color); return;
case CSSPropertyID::kStopColor:     builder.SetStopColor(style_color);     return;

2. Overwrite via Sequential Application

In CSSColorInterpolationType::ApplyStandardPropertyValue (third_party/blink/renderer/core/animation/css_color_interpolation_type.cc), the visited-resolved interpolated color is applied after the unvisited one:

void CSSColorInterpolationType::ApplyStandardPropertyValue(...) const {
  ...
  ColorPropertyFunctions::SetUnvisitedColor(...);
  ColorPropertyFunctions::SetVisitedColor(
      CssProperty(), state.StyleBuilder(),
      ResolveInterpolableColor(*color_pair.Get(kVisited),   state, true,  ...));  // Overwrites the slot!
}

Since SetVisitedColor runs second, it overwrites the shared ComputedStyle field with the visited-resolved value.

3. Visited-State Dependent Resolution

When a child element inside a link specifies an explicit color: inherit, the rule is expanded to -internal-visited-color: inherit during cascade expansion. This routes through InternalVisitedColor::ApplyInherit in third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc:

void InternalVisitedColor::ApplyInherit(StyleResolverState& state) const {
  ...
  const StyleColor& inherited_color =
      (state.ParentStyle()->InsideLink() == EInsideLink::kInsideVisitedLink)
          ? state.ParentStyle()->InternalVisitedColor()   // visited -> :visited color
          : state.ParentStyle()->Color();                 // unvisited -> :link color
  builder.SetInternalVisitedColor(inherited_color);
}

Since ParentStyle()->InsideLink() reflects the actual visited state of the link, the child element’s InternalVisitedColor() becomes conditionally set to the visited or unvisited color of the parent.

4. Read-back with No Sanitization

When a script queries getComputedStyle() for flood-color, lighting-color, or stop-color, the internal read-back functions (e.g., FloodColor::CSSValueFromComputedStyleInternal in longhands_custom.cc) retrieve the style directly from the shared field:

const CSSValue* FloodColor::CSSValueFromComputedStyleInternal(
    const ComputedStyle& style, ..., bool allow_visited_style, ...) const {
  return ComputedStyleUtils::CurrentColorOrValidColor(style, style.FloodColor(), value_phase);
}

Because allow_visited_style is ignored (as this field is designed to hold only the unvisited value), the color is returned directly, leaking the actual visited state of the link directly to the page.


Potential Steps to Trigger the Issue

An attacker could potentially exploit this behavior via the following sequence:

  1. Define a link element <a> pointing to a target URL (e.g., https://victim.example/).
  2. Style visited and unvisited links with distinct colors:
    a:link    { color: rgb(0, 255, 0); } /* green */
    a:visited { color: rgb(255, 0, 0); } /* red */
    
  3. Place an SVG filter element inside the anchor with color inheritance enabled:
    <a href="https://victim.example/">
      <svg><feFlood id="probe" style="color: inherit; animation: k 1s paused;"></feFlood></svg>
    </a>
    
  4. Bind an animation to the SVG element that maps flood-color to currentColor:
    @keyframes k {
      from, to { flood-color: currentColor; }
    }
    
  5. Force a synchronous style update and query the resolved style in JavaScript:
    const probe = document.getElementById('probe');
    const floodColor = getComputedStyle(probe).floodColor;
    if (floodColor === 'rgb(255, 0, 0)') {
      console.log('Result: VISITED');
    } else {
      console.log('Result: NOT VISITED');
    }
    

Suggested Fix

To prevent the :visited state from overwriting the default non-visited style slots, ColorPropertyFunctions::SetVisitedColor in third_party/blink/renderer/core/animation/color_property_functions.cc should return early without modifying style fields for properties that do not support independent :visited styling.

Specifically, kFloodColor, kLightingColor, and kStopColor should be treated similarly to kAccentColor:

    case CSSPropertyID::kFloodColor:
    case CSSPropertyID::kLightingColor:
    case CSSPropertyID::kStopColor:
      // These properties are not valid for :visited styling and do not have separate visited fields.
      return;

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


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