Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in CSS
DescriptionInappropriate implementation in CSS
ComponentCSS
Bug ClassLogic Error
Tracker514550047
Fix commita0e646cf4a7a (chromium/src) +44/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Files Changed

  • third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
  • third_party/blink/web_tests/external/wpt/forced-colors-mode/forced-colors-mode-61.html
From a0e646cf4a7a2c44ba508ffc4f61f008e9cf7628 Mon Sep 17 00:00:00 2001
From: Alison Maher <[email protected]>
Date: Wed, 20 May 2026 15:09:10 -0700
Subject: [PATCH] :visited and forced-color-adjust: preserve-parent-color

`Color::ApplyInherit` was writing the parent's `VisitedDependentColor`
into the child's unvisited color slot when the child used
'forced-color-adjust: preserve-parent-color'. This ended up exposing the
visited color through 'getComputedStyle()'.

Use `Color::ColorIncludingFallback` instead, so the child's unvisited
slot inherits the parent's unvisited rendered color. The visited slot is
unchanged, so painting of preserve-parent-color descendants of visited
links is unaffected.

Bug: 514550047
Change-Id: I275f5a4c9aefae6d449724c2a6f37fe79236e43d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7861520
Reviewed-by: Kevin Babbitt <[email protected]>
Commit-Queue: Alison Maher <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1633855}
---

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 2f3d09c..c344a25 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
@@ -2437,8 +2437,8 @@
 void Color::ApplyInherit(StyleResolverState& state) const {
   ComputedStyleBuilder& builder = state.StyleBuilder();
   if (builder.ShouldPreserveParentColor()) {
-    builder.SetColor(StyleColor(
-        state.ParentStyle()->VisitedDependentColor(GetCSSPropertyColor())));
+    builder.SetColor(StyleColor(GetCSSPropertyColor().ColorIncludingFallback(
+        /*visited_link=*/false, *state.ParentStyle())));
   } else {
     builder.SetColor(state.ParentStyle()->Color());
   }
diff --git a/third_party/blink/web_tests/external/wpt/forced-colors-mode/forced-colors-mode-61.html b/third_party/blink/web_tests/external/wpt/forced-colors-mode/forced-colors-mode-61.html
new file mode 100644
index 0000000..cd66307
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/forced-colors-mode/forced-colors-mode-61.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>forced-color-adjust: preserve-parent-color and :visited links</title>
+<link rel="help" href="https://www.w3.org/TR/css-color-adjust-1/#forced-colors-properties">
+<link rel="help" href="https://drafts.csswg.org/css-color/#visited">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+  .preserve {
+    forced-color-adjust: preserve-parent-color;
+    color: inherit;
+  }
+</style>
+<body>
+  <a href="" id="visited_link">
+    <span id="visited_child" class="preserve">child</span>
+  </a>
+  <a href="unvisited" id="unvisited_link">
+    <span id="unvisited_child" class="preserve">child</span>
+  </a>
+</body>
+<script>
+  test(() => {
+    assert_equals(
+      getComputedStyle(visited_child).color,
+      getComputedStyle(visited_link).color,
+      "Child of visited link with forced-color-adjust: preserve-parent-color " +
+      "must inherit the parent's unvisited rendered color via getComputedStyle().");
+
+    assert_equals(
+      getComputedStyle(unvisited_child).color,
+      getComputedStyle(unvisited_link).color,
+      "Child of unvisited link with forced-color-adjust: preserve-parent-color " +
+      "must inherit the parent's unvisited rendered color via getComputedStyle().");
+
+    assert_equals(
+      getComputedStyle(visited_child).color,
+      getComputedStyle(unvisited_child).color,
+      "getComputedStyle() must not differ between children of visited and " +
+      "unvisited links.");
+  }, "forced-color-adjust: preserve-parent-color and :visited links");
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/forced-colors-mode/forced-colors-mode-61.html b/third_party/blink/web_tests/external/wpt/forced-colors-mode/forced-colors-mode-61.html
new file mode 100644
index 0000000..cd66307
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/forced-colors-mode/forced-colors-mode-61.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>forced-color-adjust: preserve-parent-color and :visited links</title>
+<link rel="help" href="https://www.w3.org/TR/css-color-adjust-1/#forced-colors-properties">
+<link rel="help" href="https://drafts.csswg.org/css-color/#visited">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+  .preserve {
+    forced-color-adjust: preserve-parent-color;
+    color: inherit;
+  }
+</style>
+<body>
+  <a href="" id="visited_link">
+    <span id="visited_child" class="preserve">child</span>
+  </a>
+  <a href="unvisited" id="unvisited_link">
+    <span id="unvisited_child" class="preserve">child</span>
+  </a>
+</body>
+<script>
+  test(() => {
+    assert_equals(
+      getComputedStyle(visited_child).color,
+      getComputedStyle(visited_link).color,
+      "Child of visited link with forced-color-adjust: preserve-parent-color " +
+      "must inherit the parent's unvisited rendered color via getComputedStyle().");
+
+    assert_equals(
+      getComputedStyle(unvisited_child).color,
+      getComputedStyle(unvisited_link).color,
+      "Child of unvisited link with forced-color-adjust: preserve-parent-color " +
+      "must inherit the parent's unvisited rendered color via getComputedStyle().");
+
+    assert_equals(
+      getComputedStyle(visited_child).color,
+      getComputedStyle(unvisited_child).color,
+      "getComputedStyle() must not differ between children of visited and " +
+      "unvisited links.");
+  }, "forced-color-adjust: preserve-parent-color and :visited links");
+</script>
Loading diff…

Original Bug Report

reported by [email protected]

Potential History leak via forced-color-adjust: preserve-parent-color in Forced Colors mode

Flapjack, 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 cross-origin history leak exists in Blink’s handling of the forced-color-adjust: preserve-parent-color CSS property when Forced Colors mode is active. The engine incorrectly resolves and stores a parent’s visited-dependent color into a child’s unvisited style slot during color inheritance. This allows malicious JavaScript to bypass :visited privacy protections and infer the user’s browsing history using getComputedStyle().

Affected files:

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

Estimated timestamp from git blame: 2021-10-08

Summary

A potential cross-origin history leak exists in the implementation of the forced-color-adjust: preserve-parent-color CSS property when Forced Colors mode is active (e.g., Windows High Contrast mode). The vulnerability allows an attacker to programmatically determine if a URL has been visited by reading inherited style information that has bypassed visited-link protections.

Vulnerability Details

The issue originates in the Color::ApplyInherit method within third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc. This method handles how the color property is inherited from a parent element.

When forced-color-adjust: preserve-parent-color is applied and the browser is in Forced Colors mode, the element is intended to inherit its color from its parent’s rendered color. The implementation performs this by eagerly resolving the parent’s visited-dependent color:

void Color::ApplyInherit(StyleResolverState& state) const {
  ComputedStyleBuilder& builder = state.StyleBuilder();
  if (builder.ShouldPreserveParentColor()) {
    builder.SetColor(StyleColor(
        state.ParentStyle()->VisitedDependentColor(GetCSSPropertyColor())));
  } else {
    builder.SetColor(state.ParentStyle()->Color());
  }
  // ...
}

The critical flaw is that state.ParentStyle()->VisitedDependentColor(...) evaluates to the actual rendered color of the parent, which includes the effects of the :visited pseudo-class if the parent is a link. This resolved color is then stored directly into the child element’s standard color slot via builder.SetColor().

In Blink’s style system, this standard color slot is exclusively meant for the unvisited style. When JavaScript calls getComputedStyle(element).color, the bindings internally call Color::CSSValueFromComputedStyleInternal with allow_visited_style = false.

Because forced_color_adjust is kPreserveParentColor and not kAuto, ComputedStyle::ShouldForceColor() evaluates to false. Consequently, the code falls back to reading the unvisited slot via style.GetCurrentColor(). Since the standard slot was previously poisoned with the visited color during inheritance, the :visited state is leaked across origins to JavaScript.

Potential Attack Steps

Note: These are suggested/potential steps based on code analysis, as our tooling agent cannot execute code to verify the exploit dynamically.

An attacker could potentially detect if a user has visited a specific URL using the following sequence:

  1. Create a malicious webpage containing an anchor element (<a>) with its href pointing to a target URL (e.g., https://example.com).
  2. Inside the anchor, insert a child element (e.g., <span>) and apply the CSS property forced-color-adjust: preserve-parent-color.
  3. The attacker’s script checks if Forced Colors mode is active (or passively waits for users with this accessibility setting).
  4. The script calls window.getComputedStyle(spanElement).color to read the inherited color of the child element.
  5. If the returned color matches the anchor’s expected :visited color rather than its default unvisited color, the attacker deterministically concludes the URL is in the user’s history.

Suggested Fix

The root cause is that the standard color slot is being populated with a visited-dependent color. The unvisited and visited colors should remain strictly separated in the ComputedStyleBuilder.

In Color::ApplyInherit, when ShouldPreserveParentColor() is true, it should only retrieve the parent’s unvisited forced color (e.g., bypassing the VisitedDependentColor wrapper) and store that via builder.SetColor().

The inheritance of the visited forced color is already naturally handled (or should be handled) by InternalVisitedColor::ApplyInherit, which correctly sets builder.SetInternalVisitedColor(...).

Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0


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