CVE-2026-113203
Overview
Files Changed
third_party/blink/renderer/core/css/properties/longhands/longhands_custom.ccthird_party/blink/web_tests/animations/transition-visited-inherited.html
Patch
From f6c1c1d9bad10d0533203f676213864d8662a0df Mon Sep 17 00:00:00 2001
From: Kevin Babbitt <kbabbitt@microsoft.com>
Date: Thu, 17 Sep 2026 09:24:56 -0700
Subject: [PATCH] Fix leaking of visited links via inherit + CSS Transitions
Bug: 562230438
Change-Id: I2100cfcd9090749e2bbe13ce79568c90c8180790
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8421139
Commit-Queue: Kevin Babbitt <kbabbitt@microsoft.com>
Reviewed-by: Rune Lillesveen <futhark@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1699883}
---
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 63b867d..862a937 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
@@ -4874,9 +4874,9 @@
} else {
// In principle, we should always inherit the InternalVisitedColor()
// of the parent, but (as an optimization) we don't store anything in
- // that field for elements outside of visited links.
+ // that field for elements outside of links.
const StyleColor& inherited_color =
- (state.ParentStyle()->InsideLink() == EInsideLink::kInsideVisitedLink)
+ (state.ParentStyle()->InsideLink() != EInsideLink::kNotInsideLink)
? state.ParentStyle()->InternalVisitedColor()
: state.ParentStyle()->Color();
builder.SetInternalVisitedColor(inherited_color);
diff --git a/third_party/blink/web_tests/animations/transition-visited-inherited.html b/third_party/blink/web_tests/animations/transition-visited-inherited.html
new file mode 100644
index 0000000..f4fb4bfb
--- /dev/null
+++ b/third_party/blink/web_tests/animations/transition-visited-inherited.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<script src="../resources/testharness.js"></script>
+<script src="../resources/testharnessreport.js"></script>
+<style id="visited-style">
+a:visited {
+ color: orange;
+}
+</style>
+<style>
+a {
+ color: red;
+}
+a span {
+ color: inherit;
+ transition: color 1s ease;
+}
+</style>
+<a href=""><span>visited link</span></a>
+<a href="http://madeup.site.com"><span>unvisited link</span></a>
+<script>
+promise_test(() => {
+ const transitionrunPromises = [];
+ document.querySelectorAll('span').forEach(element => {
+ const promise = new Promise((resolve, reject) => {
+ let resolved = false;
+ let timeoutCountdownInFrames = 2;
+ const tick = () => {
+ requestAnimationFrame(() => {
+ if (resolved)
+ return;
+ if (--timeoutCountdownInFrames == 0)
+ reject('Timeout waiting for transition run');
+ tick();
+ });
+ }
+ element.addEventListener('transitionrun', () => {
+ resolved = true;
+ resolve();
+ });
+ tick();
+ });
+ transitionrunPromises.push(promise);
+ });
+
+ assert_equals(document.getAnimations().length, 0);
+ // The getAnimations call forced a style sync, thus changing the style now
+ // forces transitions.
+ document.getElementById('visited-style').textContent =
+ 'a:visited { color: blue; }';
+
+ const animations = document.getAnimations();
+ assert_equals(
+ animations.length, 2,
+ 'inherited transition should occur for visited and unvisited links');
+ animations.forEach(animation => {
+ const keyframes = animation.effect.getKeyframes();
+ assert_equals(keyframes[0].color, 'rgb(255, 0, 0)');
+ assert_equals(keyframes[1].color, 'rgb(255, 0, 0)');
+ });
+ // Expect two transitionrun events even though one does not have a visible
+ // effect.
+ return Promise.all(transitionrunPromises);
+}, 'Do not leak inherited link visited state via web animations API');
+</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/animations/transition-visited-inherited.html b/third_party/blink/web_tests/animations/transition-visited-inherited.html
new file mode 100644
index 0000000..f4fb4bfb
--- /dev/null
+++ b/third_party/blink/web_tests/animations/transition-visited-inherited.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<script src="../resources/testharness.js"></script>
+<script src="../resources/testharnessreport.js"></script>
+<style id="visited-style">
+a:visited {
+ color: orange;
+}
+</style>
+<style>
+a {
+ color: red;
+}
+a span {
+ color: inherit;
+ transition: color 1s ease;
+}
+</style>
+<a href=""><span>visited link</span></a>
+<a href="http://madeup.site.com"><span>unvisited link</span></a>
+<script>
+promise_test(() => {
+ const transitionrunPromises = [];
+ document.querySelectorAll('span').forEach(element => {
+ const promise = new Promise((resolve, reject) => {
+ let resolved = false;
+ let timeoutCountdownInFrames = 2;
+ const tick = () => {
+ requestAnimationFrame(() => {
+ if (resolved)
+ return;
+ if (--timeoutCountdownInFrames == 0)
+ reject('Timeout waiting for transition run');
+ tick();
+ });
+ }
+ element.addEventListener('transitionrun', () => {
+ resolved = true;
+ resolve();
+ });
+ tick();
+ });
+ transitionrunPromises.push(promise);
+ });
+
+ assert_equals(document.getAnimations().length, 0);
+ // The getAnimations call forced a style sync, thus changing the style now
+ // forces transitions.
+ document.getElementById('visited-style').textContent =
+ 'a:visited { color: blue; }';
+
+ const animations = document.getAnimations();
+ assert_equals(
+ animations.length, 2,
+ 'inherited transition should occur for visited and unvisited links');
+ animations.forEach(animation => {
+ const keyframes = animation.effect.getKeyframes();
+ assert_equals(keyframes[0].color, 'rgb(255, 0, 0)');
+ assert_equals(keyframes[1].color, 'rgb(255, 0, 0)');
+ });
+ // Expect two transitionrun events even though one does not have a visible
+ // effect.
+ return Promise.all(transitionrunPromises);
+}, 'Do not leak inherited link visited state via web animations API');
+</script>
Original Bug Report
:visited history leak via CSS transition initiation check on InternalVisitedColor
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: Blink stores a separate internal visited color channel
(InternalVisitedColor()) on each ComputedStyle so a link’s actual visited
state can influence paint only, never anything web-observable. The CSS
transition-start check breaks that invariant: the equality gate for
transition: color also compares the visited channel, so a style change that
alters only the visited slot starts a real CSSTransition. Combined with
InternalVisitedColor::ApplyInherit, which consults the parent link’s actual
visited state, web content gets a deterministic JS oracle (transitionrun /
element.getAnimations()) for whether a URL is in the user’s :visited set.
Verified end-to-end on 155.0.8055.0 (release and ASan builds) with default
visited-link partitioning enabled.
Affected files:
third_party/blink/renderer/core/css/css_property_equality.cc:394-396— transition-start equality forcolorcompares the internal visited channel (sink)third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc:4862-4879—InternalVisitedColor::ApplyInheritbranches on the parent’s actual visited state (gadget)third_party/blink/renderer/core/animation/css/css_animations.cc:2638-2643— equality gate deciding whether a transition starts; no visited-related filter anywhere on the paththird_party/blink/renderer/core/animation/css/css_animations.cc:3633-3648,third_party/blink/renderer/core/dom/element.cc:797-836—transitionrundispatch andgetAnimations()(the oracle)
Estimated timestamp from git blame: 2019-06-12 (visited-slot comparison under
its current name; the same pattern predates this under the older
VisitedLinkColor naming, file dates to 2014)
Summary
An attacker page places a probe element with color: inherit; transition: color 0.1s inside an <a href="TARGET">, then swaps the page’s own
a:visited { color: ... } rule to a fresh color. That swap changes the
link’s visited-channel color in both style worlds (visited rules are matched
structurally by LinkMatchType, not by actual state), and the probe inherits
the change into its own visited slot only if the link is actually
visited, per the InsideLink() branch in ApplyInherit.
The probe’s web-observable unvisited color never changes. But
CSSPropertyEquality::PropertiesEqual(kColor) compares the visited slot
too, so the before/after styles compare unequal exactly in the visited
world, and Blink starts a CSSTransition on color. The transition’s
existence is directly observable:
transitionrunfires on the probe (gated only on listener presence);probe.getAnimations()synchronously contains aCSSTransitionwithtransitionProperty === "color".
This is a deterministic control-flow oracle, not a timing or pixel side
channel. In the executed reproduction, getComputedStyle(probe).color
remained identical (rgb(0, 255, 0)) before and after in both worlds, and
no pixel changes (the unvisited channel interpolates lime→lime); only the
transition machinery leaks. One probe per URL; many URLs can be tested by
rewriting href and re-swapping the rule.
Root Cause Analysis
-
Actual history bit enters a child’s visited slot:
third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc:4862-4879— for an element with explicitcolor: inherit(orcurrentcolor, viaApplyValueatlonghands_custom.cc:4881-4895) inside a link, the inherited visited color depends on whether the parent link is actually visited:// longhands_custom.cc:4868-4875 // In principle, we should always inherit the InternalVisitedColor() // of the parent, but (as an optimization) we don't store anything in // that field for elements outside of visited links. const StyleColor& inherited_color = (state.ParentStyle()->InsideLink() == EInsideLink::kInsideVisitedLink) ? state.ParentStyle()->InternalVisitedColor() // <-- real history bit : state.ParentStyle()->Color(); builder.SetInternalVisitedColor(inherited_color);InsideLink()carries the real (partitioned) visited state fromVisitedLinkState::DetermineLinkStateSlowCase(third_party/blink/renderer/core/dom/visited_link_state.cc:182). -
Plain
color: inheritreaches the gadget: rules on elements inside links keep link-match-typekMatchAll(third_party/blink/renderer/core/css/element_rule_collector.cc:279-285), and cascade expansion duplicates every kMatchAll declaration onto its visited counterpart (third_party/blink/renderer/core/css/resolver/cascade_expansion-inl.h:72-77), so the author’scolor: inheritalso applies-internal-visited-color: inherit, invoking the applier above. -
The transition-start gate consumes the visited slot:
third_party/blink/renderer/core/animation/css/css_animations.cc:2638-2643decides whether to start a transition viaComputedTransitionValuesEqual→CSSPropertyEquality::PropertiesEqual:// css_property_equality.cc:394-396 case CSSPropertyID::kColor: return ValueEquals(a.Color(), b.Color()) && ValueEquals(a.InternalVisitedColor(), b.InternalVisitedColor()); // <-- visited slotA visited-slot-only difference therefore starts a transition. Nothing on the path filters on visited state, and color is non-discretely interpolable, so
StartTransitionis reached (css_animations.cc:2831). -
The transition is web-observable: the new transition is materialized as a playing
CSSTransitionwith aTransitionEventDelegate(css_animations.cc:2486-2528);transitionrunis enqueued on the first phase change (css_animations.cc:3633-3648) and the animation appears inelement.getAnimations()(element.cc:797-836), regardless of whether any pixel changes.
Reproduction
- Serve:
<style id=vs>a:visited { color: red; }</style> <style>a:link { color: lime; } #probe { color: inherit; transition: color 0.1s; }</style> <a href="https://victim.example/"><span id=probe>x</span></a> - Register
probe.addEventListener('transitionrun', handler), force initial style (void getComputedStyle(probe).color), and wait tworequestAnimationFrames so rendering has begun (transitions are suppressed before first paint,css_animations.cc:3041-3045). - Swap the rule:
vs.textContent = 'a:visited { color: blue; }'. - Read the oracle:
probe.getAnimations().lengthand/or thetransitionrunevent. - Expected (correct implementation): transition start must depend only on web-observable (unvisited) computed values — no transition in either world, since the unvisited color is lime before and after.
Observed (executed, deterministic on first attempt on both release and ASan
builds, 155.0.8055.0): with the link visited, a CSSTransition on color
is created — transitionrun fires and getAnimations() is non-empty; with
the link unvisited, no transition, no event, empty list. A never-visited
control link stayed negative and an always-visited self-link control stayed
positive throughout. getComputedStyle(probe).color stayed rgb(0, 255, 0) in all cases, and the history entry was created by a real same-origin
top-level link-click navigation, so the leak works with the default-enabled
PartitionVisitedLinkDatabaseWithSelfLinks partitioning.
Severity
low — user-information disclosure of a browsing-history bit to arbitrary web content, with no memory-safety impact (renderer logic bug). The oracle is deterministic (control-flow, not timing), but the leak is one bit per probe and default visited-link partitioning confines it to URLs visited from the same top-level site + frame origin (plus self-links), i.e. an info leak of limited scope. This matches the established rating for other :visited-state oracles of the same class.
Suggested Fix Direction
Base the transition-start decision on unvisited values only: drop the
InternalVisited* comparisons from CSSPropertyEquality::PropertiesEqual
(experimentally confirmed for kColor: comparing only Color() kills the
oracle while visited paint still applies). Note every color property case
in that switch compares its visited slot (background, borders, caret,
outline, SVG paints, etc.); only kColor is exploitable today because only
InternalVisitedColor::ApplyInherit consults actual visited state — the
generated visited-property appliers inherit from the parent’s unvisited
getter — but all visited-slot comparisons should be removed for defense in
depth.
POC: :visited history leak via CSS transition-start check (PropertiesEqual(kColor) compares InternalVisitedColor)
Strategy: web-content Bug Type: information disclosure (browsing-history bit, CWE-200) — pure logic bug, no memory corruption
Web content learns whether a URL is in the user’s :visited set through a
deterministic control-flow oracle: a style change that touches only the
internal visited color channel starts a real CSSTransition, observable via
transitionrun events and element.getAnimations(), while
getComputedStyle() stays identical in both worlds.
- Gadget —
InternalVisitedColor::ApplyInherit(third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc:4862): for an element with explicitcolor: inheritinside a link, the visited slot inherits the parent’s visited slot only if the parent link is actually visited (real history bit); otherwise the parent’s unvisited color. - Sink —
CSSPropertyEquality::PropertiesEqual(kColor)(third_party/blink/renderer/core/css/css_property_equality.cc:394): the transition-start equality gate (CSSAnimations::CalculateTransitionUpdateForPropertyHandle,css_animations.cc:2638) also comparesInternalVisitedColor(), so a visited-slot-only difference starts a JS-observable transition.
Prerequisites
- A Chromium desktop build (verified on 155.0.8055.0, both an ASAN build and a release build — identical results; no sanitizer needed, the oracle is pure logic).
- Python 3 for the local web server.
- Environment-only Chrome flags used below:
--no-first-run--no-default-browser-check--user-data-dir--no-proxy-server--ignore-gpu-blocklist(sandbox/display plumbing; none affect the bug). No security features are disabled;PartitionVisitedLinkDatabaseWithSelfLinksis stable/default in this tree and stays enabled — the POC creates its history entry with a real same-origin top-level link-click navigation, so the visit lands inside the correct<link_url, top_level_site, frame_origin>partition.
Files
-
poc.html— the oracle page. Three probe links, each wrapping a<span class=probe>withcolor: inherit; transition: color 0.1s:- A →
/target-visited.html(gets genuinely visited during the run) - B →
/target-unvisited.html(never visited — negative control) - C →
href=""(self link, always visited — positive control)
One oracle run = swap the
a:visited { color: … }rule to a fresh color (changes every link’s visited slot in both style worlds, structurally), then test each probe for a newCSSTransitiononcolorviagetAnimations()andtransitionrun. Phase 1 records the baseline, clicks link A;target-visited.htmlbounces back to phase 2, which re-runs the oracle and reports the verdict. - A →
-
target-visited.html,target-unvisited.html— navigation targets. -
server.py— serves the files onhttp://localhost:8080and appends/report?...query strings toresults.log.
Reproduction
cd /poc-artifacts
python3 server.py & # writes oracle reports to results.log
rm -rf /tmp/chrome-poc-profile # fresh profile => empty history
/chromium_src/src/out/asan/chrome \
--no-first-run --no-default-browser-check --ignore-gpu-blocklist \
--no-proxy-server --user-data-dir=/tmp/chrome-poc-profile \
http://localhost:8080/poc.html
# wait ~20s, then:
cat results.log
The run is fully automatic (baseline → link-click visit → re-test).
Expected Result
results.log gains two lines:
phase=1(empty history):A=UNVISITED B=UNVISITED C=VISITED— C is the always-visited self-link proving the oracle machinery works.phase=2(after the single visit to/target-visited.html):A=VISITED B=UNVISITED,eventA=true,attempts=1,leak=CONFIRMED— the history bit was read back purely from the existence of a CSS transition.
In every report, gcs* fields show getComputedStyle(probe).color is
rgb(0, 255, 0) before and after in both worlds — the classic oracle
stays sealed; only the transition machinery leaks.
Output
Actual captured output (ASAN build, results-asan.log; release build in
results-release.log is identical):
phase=1&A=UNVISITED&B=UNVISITED&C=VISITED&eventA=false&eventB=false&eventC=true&gcsA=rgb(0, 255, 0)=>rgb(0, 255, 0)&gcsB=rgb(0, 255, 0)=>rgb(0, 255, 0)&gcsC=rgb(0, 255, 0)=>rgb(0, 255, 0)
phase=2&A=VISITED&B=UNVISITED&C=VISITED&eventA=true&eventB=false&eventC=true&gcsA=rgb(0, 255, 0)=>rgb(0, 255, 0)&gcsB=rgb(0, 255, 0)=>rgb(0, 255, 0)&gcsC=rgb(0, 255, 0)=>rgb(0, 255, 0)&attempts=1&leak=CONFIRMED
screenshot-phase2.png shows the phase-2 page: probes A and C are painted in
the (user-visible-only) visited color while B stays lime, alongside the JS
verdict RESULT: history leak CONFIRMED (A=VISITED B=UNVISITED C=VISITED).
Both runs were deterministic: first attempt, no timing games, no pixel readback.
Bandaid Fix
bandaid-fix.patch makes CSSPropertyEquality::PropertiesEqual(kColor)
compare only the web-observable unvisited channel, so a visited-slot-only
difference no longer starts a transition (the long-standing anti-:visited
rule: visited state may influence paint only, never web-observable control
flow). Call-site audit: the other users of PropertiesEqual
(keyframe_effect_model.cc:266 compositor keyframe re-snapshot;
style_resolver.cc:321-341 which only queries image/clip-path properties)
are unaffected or benefit. Only kColor is exploitable today because only
InternalVisitedColor has an actual-visited-state branch in its inherit
applier; a complete upstream fix should still drop the visited-slot
comparison from all color properties in this switch for defense in depth.
Verification (release build rebuilt with the patch, same POC run) — the
oracle is dead even for the always-visited control C, across all retries,
while the links still genuinely carry visited state (probes A and C still
paint in the visited color, screenshot-phase2-fixed.png). Captured in
results-fixed.log:
phase=1&A=UNVISITED&B=UNVISITED&C=UNVISITED&eventA=false&eventB=false&eventC=false&...
phase=2&A=UNVISITED&B=UNVISITED&C=UNVISITED&eventA=false&eventB=false&eventC=false&...&attempts=7&leak=NOT-CONFIRMED
The patch was reverted from the tree after verification.
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.
- http://localhost:8080
- http://localhost:8080/poc.html
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/animation/css/css_animations.cc;l=2638
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/animation/css/css_animations.cc;l=3633
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/css_property_equality.cc;l=394
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/element_rule_collector.cc;l=279
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc;l=4862
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/resolver/cascade_expansion-inl.h;l=72
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/element.cc;l=797
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/visited_link_state.cc;l=182