Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in SVG
DescriptionPolicy bypass in SVG
ComponentSVG
Bug ClassLogic Error
Tracker502631225
Fix commit3bcf4e658cdc (chromium/src) +30/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • third_party/blink/renderer/core/svg/svg_animated_color.cc
  • third_party/blink/web_tests/external/wpt/svg/animations/animate-currentcolor-in-visited-link.html
From 3bcf4e658cdcc6e18287c949a92b97cdab9aad70 Mon Sep 17 00:00:00 2001
From: Fredrik Söderquist <fs@opera.com>
Date: Wed, 22 Apr 2026 15:00:55 -0700
Subject: [PATCH] Always resolve SMIL animated 'currentcolor' using the unvisited 'color'

Fixed: 502631225
Change-Id: I342a5d303a6366b0b9f8763b34d81b7335e5632f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7764330
Commit-Queue: Philip Rogers <pdr@chromium.org>
Auto-Submit: Fredrik Söderquist <fs@opera.com>
Reviewed-by: Philip Rogers <pdr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1619117}
---

diff --git a/third_party/blink/renderer/core/svg/svg_animated_color.cc b/third_party/blink/renderer/core/svg/svg_animated_color.cc
index 1a2c70a..28e9a99 100644
--- a/third_party/blink/renderer/core/svg/svg_animated_color.cc
+++ b/third_party/blink/renderer/core/svg/svg_animated_color.cc
@@ -60,8 +60,11 @@
 }
 
 Color FallbackColorForCurrentColor(const SVGElement& target_element) {
+  // As a workaround, always use the unvisited 'color' when resolving a
+  // potential 'currentcolor' value to prevent leaking :visited state.
   if (const ComputedStyle* target_style = target_element.GetComputedStyle()) {
-    return target_style->VisitedDependentColor(GetCSSPropertyColor());
+    return GetCSSPropertyColor().ColorIncludingFallback(
+        /*visited_link=*/false, *target_style, /*is_current_color=*/nullptr);
   }
   return Color::kTransparent;
 }
diff --git a/third_party/blink/web_tests/external/wpt/svg/animations/animate-currentcolor-in-visited-link.html b/third_party/blink/web_tests/external/wpt/svg/animations/animate-currentcolor-in-visited-link.html
new file mode 100644
index 0000000..be03258
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/svg/animations/animate-currentcolor-in-visited-link.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<title>SMIL animating 'currentcolor' on target within visited link</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<link rel="help" href="https://drafts.csswg.org/selectors/#visited-privacy">
+<style>
+  a:link    { color: blue; }
+  a:visited { color: lightblue; }
+</style>
+<a href="">
+  <svg>
+    <rect id="r" width="100" height="100">
+      <set attributeName="fill" to="currentcolor"/>
+    </rect>
+  </svg>
+</a>
+<script>
+  async_test(t => {
+    const rect = document.getElementById('r');
+    const set = rect.firstElementChild;
+    set.onbegin = t.step_func_done(() => {
+      const style = getComputedStyle(rect);
+      assert_equals(style.fill, style.color, `currentcolor animation does not leak :visited`);
+    });
+  });
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/svg/animations/animate-currentcolor-in-visited-link.html b/third_party/blink/web_tests/external/wpt/svg/animations/animate-currentcolor-in-visited-link.html
new file mode 100644
index 0000000..be03258
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/svg/animations/animate-currentcolor-in-visited-link.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<title>SMIL animating 'currentcolor' on target within visited link</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<link rel="help" href="https://drafts.csswg.org/selectors/#visited-privacy">
+<style>
+  a:link    { color: blue; }
+  a:visited { color: lightblue; }
+</style>
+<a href="">
+  <svg>
+    <rect id="r" width="100" height="100">
+      <set attributeName="fill" to="currentcolor"/>
+    </rect>
+  </svg>
+</a>
+<script>
+  async_test(t => {
+    const rect = document.getElementById('r');
+    const set = rect.firstElementChild;
+    set.onbegin = t.step_func_done(() => {
+      const style = getComputedStyle(rect);
+      assert_equals(style.fill, style.color, `currentcolor animation does not leak :visited`);
+    });
+  });
+</script>
Loading diff…

Original Bug Report

reported by vm...@google.com

Cross-origin history leak via SVG SMIL currentColor animation

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 without the Chrome Security team.

Overview: A logic error in SVG SMIL animations eagerly resolves the currentColor keyword using visited-dependent styles. This resolved absolute color is then applied to the CSS cascade, overriding the element’s unvisited style slot with the visited color. This allows JavaScript to deterministically read the user’s cross-origin browsing history via getComputedStyle().

Affected files:

  • third_party/blink/renderer/core/svg/svg_animated_color.cc
  • third_party/blink/renderer/core/svg/svg_animate_element.cc
  • third_party/blink/renderer/core/css/resolver/style_resolver.cc
  • third_party/blink/renderer/core/css/element_rule_collector.cc
  • third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc

Estimated timestamp from git blame: 2025-10-21

Description

A potential vulnerability exists in the handling of SVG SMIL animations for color properties, leading to a deterministic cross-origin history leak. This allows an attacker to bypass the dual-style mitigation for :visited links and read whether a specific URL is in the user’s history.

When a SMIL animation (e.g., <animate>) targets a color attribute using currentColor inside a visited link, the following sequence occurs:

  1. Eager Resolution: During animation processing, SVGColorProperty::CalculateAnimatedValue calculates the frame’s color. To handle currentColor, it calls FallbackColorForCurrentColor, which queries the element’s ComputedStyle via VisitedDependentColor. Because the element is inside a visited link, this returns the absolute visited color.
  2. Semantic Loss: The animation engine converts this absolute color back to a StyleColor, permanently losing the currentColor keyword semantic. The animation result is then serialized to an absolute RGB string (e.g., "rgb(255, 255, 255)").
  3. Cascade Laundering: SVGAnimateElement::ApplyResultsToTarget applies this absolute string as a SMIL style property. During style resolution, StyleResolver adds these properties to the cascade. Because the element is inside a link, the match type is set to CSSSelector::kMatchAll.
  4. Slot Override: In StyleCascade::CollectFromMatchResult, kMatchAll causes the property to be expanded into both its unvisited and visited (-internal-visited-*) slots. Consequently, the unvisited style slot is overwritten with the absolute visited color.
  5. Information Leak: When JavaScript calls getComputedStyle().fill, which is designed to only return the unvisited style, it returns the absolute visited color, successfully leaking the history state.

Impact

This provides a deterministic, zero-interaction readback of a user’s cross-origin browsing history. While PartitionVisitedLinkDatabase mitigates the impact on standard Chrome (partitioning history by top-level site), the vulnerability exposes unpartitioned cross-origin history on Android WebView, where link partitioning is explicitly disabled.

Potential Reproduction Steps

(Note: These are suggested steps based on code analysis; our tooling agent cannot execute code to verify.)

  1. Serve a page containing the following structure:
    <style>
      a:link { color: #000; }
      a:visited { color: #fff; }
    </style>
    <a href='https://target.example/'>
      <svg>
        <rect id='target' width='10' height='10'>
          <animate attributeName='fill' from='currentColor' to='currentColor' begin='0s' dur='1s' fill='freeze'/>
        </rect>
      </svg>
    </a>
    
  2. Wait for the animation to apply (e.g., using requestAnimationFrame twice).
  3. Read the computed style of the rectangle: const color = getComputedStyle(document.getElementById('target')).fill;
  4. If the URL https://target.example/ has been visited, color will be 'rgb(255, 255, 255)'. Otherwise, it will be 'rgb(0, 0, 0)'.

Suggested Fix

The root cause is the eager resolution of currentColor during the SMIL animation phase, which drops the keyword semantic and locks in the visited color.

To fix this, SMIL animations should ideally preserve the currentColor keyword (or UnresolvedColorFunction semantics) throughout the animation calculation and serialization phases, delaying resolution until the final ComputedStyle is built. Alternatively, if eager resolution is required for interpolation, the engine must track whether the resulting color was influenced by a visited state and ensure that the serialized SMIL property is applied exclusively to the visited style slot, rather than broadcasting it to the unvisited slot via kMatchAll.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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