Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in CSS
DescriptionInappropriate implementation in CSS
ComponentCSS
Bug ClassLogic Error
Tracker518103887
Fix commit73a3f1d8f0cb (chromium/src) +69/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/core/css/css_image_value_test.cc
modified
if
third_party/blink/renderer/core/css/css_image_value_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/css/css_image_value_test.cc
  • third_party/blink/renderer/core/css/css_url_data.cc
  • third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html
From 73a3f1d8f0cb4fb72db53ca351f111d339f9f5b0 Mon Sep 17 00:00:00 2001
From: Kevin Babbitt <[email protected]>
Date: Wed, 03 Jun 2026 05:53:12 -0700
Subject: [PATCH] Preserve original CSS URL when generating computed value

Similar fix to https://crrev.com/c/7859244 in a different spot to
maintain the dangling markup mitigation.

Fixed: 518103887
Change-Id: Id77b349b2c8901602a6c6b0a5421e12f047b8845
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7895140
Reviewed-by: Rune Lillesveen <[email protected]>
Commit-Queue: Kevin Babbitt <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1640871}
---

diff --git a/third_party/blink/renderer/core/css/css_image_value_test.cc b/third_party/blink/renderer/core/css/css_image_value_test.cc
index 85afbde3..3f12c03 100644
--- a/third_party/blink/renderer/core/css/css_image_value_test.cc
+++ b/third_party/blink/renderer/core/css/css_image_value_test.cc
@@ -149,4 +149,56 @@
   }
 }
 
+// Ensure that CSSUrlData::MakeComputed() does not launder the dangling markup
+// flag by overwriting relative_url_ with the canonicalized absolute URL.
+// The attack vector: style resolution creates a computed value via
+// MakeComputed(), then commitStyles() serializes it (via CssText(), which
+// uses relative_url_) and re-parses it on the element's inline style. If
+// relative_url_ was overwritten with the canonical form, the re-parsed URL
+// lacks newlines and the dangling markup flag is lost.
+TEST_F(CSSImageValueTest, MakeComputedDoesNotLaunderDanglingMarkup) {
+  SimRequest main_resource("https://example.com/index.html", "text/html");
+
+  LoadURL("https://example.com/index.html");
+
+  main_resource.Complete(R"HTML(
+    <!doctype html>
+    <style>
+      @keyframes anim {
+        from { background-image: url('/exfil?\a <secret'); }
+        to   { background-image: url('/exfil?\a <secret'); }
+      }
+      #target {
+        animation: anim 1s paused;
+        width: 100px;
+        height: 100px;
+      }
+    </style>
+    <div id="target"></div>
+  )HTML");
+
+  test::RunPendingTasks();
+  GetDocument().UpdateStyleAndLayoutTree();
+  Compositor().BeginFrame();
+
+  // commitStyles() serializes the computed animation style (which goes
+  // through MakeComputed()) and sets it on the inline style. If the fix
+  // is missing, the re-parsed URL loses the dangling markup flag.
+  MainFrame().ExecuteScript(WebScriptSource(R"JS(
+    const target = document.getElementById('target');
+    target.getAnimations()[0].commitStyles();
+  )JS"));
+
+  GetDocument().UpdateStyleAndLayoutTree();
+  Compositor().BeginFrame();
+
+  auto* target = GetDocument().getElementById(AtomicString("target"));
+  ASSERT_TRUE(target);
+  const StyleImage* image =
+      target->ComputedStyleRef().BackgroundLayers().GetImage();
+  if (image) {
+    EXPECT_TRUE(image->ErrorOccurred());
+  }
+}
+
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/css/css_url_data.cc b/third_party/blink/renderer/core/css/css_url_data.cc
index 3788e0a..fd87cc0 100644
--- a/third_party/blink/renderer/core/css/css_url_data.cc
+++ b/third_party/blink/renderer/core/css/css_url_data.cc
@@ -167,10 +167,16 @@
   if (relative_url_.empty() || is_local_ || absolute_url_.empty()) {
     return this;
   }
+  // When the URL was flagged as potentially dangling markup, keep the raw
+  // relative spelling so that CssText() round-trips a string that re-derives
+  // PotentiallyDanglingMarkup() on the consuming side (e.g. via
+  // commitStyles()). Using the canonicalized serialization would launder away
+  // the newline/'<' characters. This matches the logic in MakeResolved().
   return MakeGarbageCollected<CSSUrlData>(
-      base::PassKey<CSSUrlData>(), absolute_url_, absolute_url_, Referrer(),
-      is_from_origin_clean_style_sheet_, is_ad_related_, is_local_,
-      potentially_dangling_markup_, modifiers_);
+      base::PassKey<CSSUrlData>(),
+      potentially_dangling_markup_ ? relative_url_ : absolute_url_,
+      absolute_url_, Referrer(), is_from_origin_clean_style_sheet_,
+      is_ad_related_, is_local_, potentially_dangling_markup_, modifiers_);
 }
 
 const CSSUrlData* CSSUrlData::MakeResolved(const KURL& base_url,
diff --git a/third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html b/third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html
index 2464b48..b24f2c9 100644
--- a/third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html
+++ b/third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html
@@ -8,10 +8,15 @@
 <div id="target"></div>
 <script>
   test(() => {
-    const url_without_base = `url("${new URL("img.gif", location.href)}")`;
     target.style.listStyleImage = "url(img.gif)";
     base_elm.remove();
-    assert_equals(getComputedStyle(target).listStyleImage, url_without_base,
-                  "The url is re-resolved after the base change");
+    // The base element had a dangling markup href (containing newline and '<').
+    // Because potentially_dangling_markup is set, the raw relative URL is
+    // preserved through MakeComputed() to prevent laundering the dangling
+    // markup flag during serialization/re-parse cycles (e.g. commitStyles()).
+    // This is a deliberate deviation from CSS Values §4.5.1 (which says
+    // computed url() should be absolute) for this security edge case.
+    assert_equals(getComputedStyle(target).listStyleImage, 'url("img.gif")',
+                  "The dangling-markup url preserves the relative form");
   }, "Removing the base element should not crash.");
 </script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/css/css_image_value_test.cc b/third_party/blink/renderer/core/css/css_image_value_test.cc
index 85afbde3..3f12c03 100644
--- a/third_party/blink/renderer/core/css/css_image_value_test.cc
+++ b/third_party/blink/renderer/core/css/css_image_value_test.cc
@@ -149,4 +149,56 @@
   }
 }
 
+// Ensure that CSSUrlData::MakeComputed() does not launder the dangling markup
+// flag by overwriting relative_url_ with the canonicalized absolute URL.
+// The attack vector: style resolution creates a computed value via
+// MakeComputed(), then commitStyles() serializes it (via CssText(), which
+// uses relative_url_) and re-parses it on the element's inline style. If
+// relative_url_ was overwritten with the canonical form, the re-parsed URL
+// lacks newlines and the dangling markup flag is lost.
+TEST_F(CSSImageValueTest, MakeComputedDoesNotLaunderDanglingMarkup) {
+  SimRequest main_resource("https://example.com/index.html", "text/html");
+
+  LoadURL("https://example.com/index.html");
+
+  main_resource.Complete(R"HTML(
+    <!doctype html>
+    <style>
+      @keyframes anim {
+        from { background-image: url('/exfil?\a <secret'); }
+        to   { background-image: url('/exfil?\a <secret'); }
+      }
+      #target {
+        animation: anim 1s paused;
+        width: 100px;
+        height: 100px;
+      }
+    </style>
+    <div id="target"></div>
+  )HTML");
+
+  test::RunPendingTasks();
+  GetDocument().UpdateStyleAndLayoutTree();
+  Compositor().BeginFrame();
+
+  // commitStyles() serializes the computed animation style (which goes
+  // through MakeComputed()) and sets it on the inline style. If the fix
+  // is missing, the re-parsed URL loses the dangling markup flag.
+  MainFrame().ExecuteScript(WebScriptSource(R"JS(
+    const target = document.getElementById('target');
+    target.getAnimations()[0].commitStyles();
+  )JS"));
+
+  GetDocument().UpdateStyleAndLayoutTree();
+  Compositor().BeginFrame();
+
+  auto* target = GetDocument().getElementById(AtomicString("target"));
+  ASSERT_TRUE(target);
+  const StyleImage* image =
+      target->ComputedStyleRef().BackgroundLayers().GetImage();
+  if (image) {
+    EXPECT_TRUE(image->ErrorOccurred());
+  }
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html b/third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html
index 2464b48..b24f2c9 100644
--- a/third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html
+++ b/third_party/blink/web_tests/wpt_internal/css/css-values/urls/base-change-dangling-href.html
@@ -8,10 +8,15 @@
 <div id="target"></div>
 <script>
   test(() => {
-    const url_without_base = `url("${new URL("img.gif", location.href)}")`;
     target.style.listStyleImage = "url(img.gif)";
     base_elm.remove();
-    assert_equals(getComputedStyle(target).listStyleImage, url_without_base,
-                  "The url is re-resolved after the base change");
+    // The base element had a dangling markup href (containing newline and '<').
+    // Because potentially_dangling_markup is set, the raw relative URL is
+    // preserved through MakeComputed() to prevent laundering the dangling
+    // markup flag during serialization/re-parse cycles (e.g. commitStyles()).
+    // This is a deliberate deviation from CSS Values §4.5.1 (which says
+    // computed url() should be absolute) for this security edge case.
+    assert_equals(getComputedStyle(target).listStyleImage, 'url("img.gif")',
+                  "The dangling-markup url preserves the relative form");
   }, "Removing the base element should not crash.");
 </script>
Loading diff…

Original Bug Report

reported by [email protected]

Bypass of dangling markup protection via CSSUrlData::MakeComputed

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 logical vulnerability in CSSUrlData::MakeComputed can potentially bypass Blink’s dangling markup subresource-blocking defense. When generating a computed CSS URL value, the raw relative URL is erroneously overwritten with the canonicalized absolute URL where newlines are stripped. Subsequent serialization and re-parsing cycles of this value can cause the subresource load to bypass the security block.

Affected files:

  • third_party/blink/renderer/core/css/css_url_data.cc

Estimated timestamp from git blame: 2025-05-12

Description

There is a potential logical bypass of Blink’s dangling markup subresource-blocking defense during the serialization and re-parsing of computed CSS URL values. When CSSUrlData::MakeComputed() clones a URL data instance for a computed style, it overwrites the relative_url_ (unresolved URL) with the canonicalized absolute_url_. Because URL canonicalization strips removable whitespace characters (such as \n, \r, and \t), the raw whitespace characters that originally triggered the dangling markup detection are lost. When this computed value is serialized and then parsed again by the browser, the new URL is not recognized as potentially dangling, allowing the subresource request to bypass the safety block.

Root Cause

In third_party/blink/renderer/core/css/css_url_data.cc:

const CSSUrlData* CSSUrlData::MakeComputed() const {
  if (relative_url_.empty() || is_local_ || absolute_url_.empty()) {
    return this;
  }
  return MakeGarbageCollected<CSSUrlData>(
      base::PassKey<CSSUrlData>(), absolute_url_, absolute_url_, Referrer(),
      is_from_origin_clean_style_sheet_, is_ad_related_, is_local_,
      potentially_dangling_markup_, modifiers_);
}

Here, absolute_url_ is passed as both parameters 2 and 3 (the unresolved and resolved URL arguments). This overwrites the new object’s relative_url_ with the canonicalized absolute URL string.

When CssText() is called, it serializes using relative_url_:

String CSSUrlData::CssText() const {
  return SerializeURI(relative_url_, modifiers_);
}

Because relative_url_ was overwritten, the returned string contains the canonical absolute URL (where the raw newlines are absent). If this serialized string is re-parsed, the URL parser’s whitespace checking code (in url/url_canon_etc.cc’s DoRemoveUrlWhitespace) will not flag the new URL as potentially dangling since it lacks raw whitespace characters. Thus, the dangling markup flag is lost on re-parsing.

Potential Trigger Steps

(Note: These are potential, theoretical steps as our tooling does not currently have the capability to run code to verify with a live proof of concept)

  1. An attacker injects a CSS URL value containing unclosed quotes and a newline followed by a < character (e.g., url('/exfil?\a <secret'), where \a is the hex escape for a newline).
  2. On the initial parse, potentially_dangling_markup_ is set to true. When Blink tries to load the resource, the fetch is blocked in BaseFetchContext::CanRequestInternal() because the URL’s PotentiallyDanglingMarkup() flag is true.
  3. A script or layout operation triggers style resolution, creating a computed CSS value. CSSImageValue::ComputedCSSValue() calls url_data_->MakeComputed(), which incorrectly overwrites relative_url_ with the canonicalized absolute_url_ (removing the newline).
  4. The application triggers style serialization and re-parsing. For example, calling animation.commitStyles() serializes the computed style via CssText() and immediately sets it back on the element’s inline style attribute.
  5. The serialized CSS contains the canonicalized absolute URL string which has no raw newlines.
  6. Upon re-parsing, the new KURL is constructed with PotentiallyDanglingMarkup() set to false.
  7. Blink fetches the image resource successfully without blocking, potentially exfiltrating the captured page bytes.

Suggested Fix

Modify CSSUrlData::MakeComputed() to preserve the raw unresolved relative_url_ when potentially_dangling_markup_ is true, matching the logic implemented in MakeResolved():

const CSSUrlData* CSSUrlData::MakeComputed() const {
  if (relative_url_.empty() || is_local_ || absolute_url_.empty()) {
    return this;
  }
  return MakeGarbageCollected<CSSUrlData>(
      base::PassKey<CSSUrlData>(),
      potentially_dangling_markup_ ? relative_url_ : absolute_url_,
      absolute_url_, Referrer(), is_from_origin_clean_style_sheet_,
      is_ad_related_, is_local_, potentially_dangling_markup_, modifiers_);
}

Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040


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