CVE-2026-87550
Overview
Background
- Custom property (CSS variable)
- an author-defined property such as
--a\,failwhose name may contain characters (like,) that must be escaped when written back out as CSS text. - `CSSUnparsedValue`
- the Typed OM object that holds an unparsed CSS value as a list of string and
var()reference segments and can serialize itself back to a CSS string. - `SerializeIdentifier`
- a Blink helper in
css_markup.hthat emits an identifier with the CSS escaping rules applied so the output re-parses to the same identifier. - Round-trip serialization
- the requirement that a value serialized to text and then re-parsed yields the same value; a name that is not escaped on output breaks this invariant.
Root Cause Analysis
When a CSSUnparsedValue containing a var() reference is serialized, the code path builds the string var( followed by the referenced custom property name. The removed line builder.Append(reference_value->variable()) appended the raw variable name directly, performing no CSS escaping of that identifier. Because custom property names can legally contain characters that are not valid in a bare CSS identifier (the test uses --a\,fail, whose comma must be escaped), the un-escaped output does not re-parse to the original name, so a value like var(--a\,fail) serializes back as var(--a,fail), which refers to a different (or malformed) property.
The fix replaces the raw append with SerializeIdentifier(reference_value->variable(), builder), which applies the identifier escaping rules so the emitted var() name is properly escaped and round-trips correctly, and adds the #include of css_markup.h for that helper. This is purely the addition of correct output encoding on the serialization path.
var() reference as raw text instead of an escaped CSS identifier; the fix routes that name through SerializeIdentifier, so characters requiring escaping are encoded and the serialized value re-parses to the same property.Attack Path
- Author a custom property with an escape-requiring name
Create an element whose style defines a custom property whose name contains a character needing escaping, e.g.
--a\,fail: pass, and another property--unparsed: var(--a\,fail)that references it. - Obtain the unparsed value via Typed OM
Read the value through
attributeStyleMap.get("--unparsed"), producing aCSSUnparsedValuewhose segment is avar()reference to--a\,fail. - Serialize without escaping
Convert the value to a string (e.g.
specified.toString()); on the pre-patch path the referenced name is appended raw, so the output becomesvar(--a,fail)rather than the escapedvar(--a\,fail). - Re-parse yields a different property
Feed the serialized string back with
setProperty; the un-escaped name now parses as a different/incorrect variable reference, so the computed value no longer resolves to the originalpass, breaking the round-trip and corrupting the intended CSS output. Any further security consequence beyond incorrect/mis-encoded CSS output is(inferred)and not shown by the diff.
Impact Assessment
var() reference is emitted without escaping its identifier, so the serialized-then-reparsed value denotes a different property than intended. This runs in the renderer process during CSS serialization; the precondition is a custom property name that contains characters requiring escaping being carried through a var() reference in a CSSUnparsedValue. The advisory classifies this as a LogicError / improper encoding in CSS; exploited-in-the-wild status and any escalation beyond the incorrect output the patch corrects are not stated and are (inferred).Files Changed
third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.htmlthird_party/blink/renderer/core/css/cssom/css_unparsed_value.cc
Audit Directions
- Raw identifier append during serializationSearch other Typed OM / CSSOM serializers for
builder.Append(...)of a property, variable, or identifier name that bypassesSerializeIdentifier/SerializeString, since any such raw append can emit un-escaped output. - var() and custom-property emission pathsReview every code path that reconstructs
var(references or custom property names (fallbacks, declarations, computed-style output) to confirm each name is escaped and the output round-trips. - Round-trip test coverageLook for serialization code that lacks a serialize-then-reparse test with escape-requiring names (commas, whitespace, control characters), as these gaps hide encoding bugs like this one.
Patch
From 9ef64552f77b59e11090cdb22ce4c72592c52ddd Mon Sep 17 00:00:00 2001
From: Rune Lillesveen <futhark@chromium.org>
Date: Mon, 03 Aug 2026 03:36:13 -0700
Subject: [PATCH] Escape custom property names for unparsed serialization
Bug: 518082852
Change-Id: Ie31aebb22cd83730d44869f475ff3c1ec37b4078
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8180666
Reviewed-by: Javier Fernandez <jfernandez@igalia.com>
Commit-Queue: Rune Lillesveen <futhark@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1672568}
---
diff --git a/third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc b/third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc
index 42b91b5e..d4e0189 100644
--- a/third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc
+++ b/third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc
@@ -5,6 +5,7 @@
#include "third_party/blink/renderer/core/css/cssom/css_unparsed_value.h"
#include "css_style_value.h"
+#include "third_party/blink/renderer/core/css/css_markup.h"
#include "third_party/blink/renderer/core/css/css_unparsed_declaration_value.h"
#include "third_party/blink/renderer/core/css/css_variable_data.h"
#include "third_party/blink/renderer/core/css/cssom/css_style_variable_reference_value.h"
@@ -244,7 +245,7 @@
const auto* reference_value =
segments_[i]->GetAsCSSVariableReferenceValue();
builder.Append("var(");
- builder.Append(reference_value->variable());
+ SerializeIdentifier(reference_value->variable(), builder);
if (reference_value->fallback()) {
builder.Append(",");
if (!reference_value->fallback()->AppendUnparsedString(
diff --git a/third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.html b/third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.html
index 6e9b1a0..691dfb0 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.html
@@ -8,6 +8,7 @@
<body>
<div id="tag"></div>
<div id="elem" style="color-scheme: bar/*comment*/var(--foo)"></div>
+<div id="customEscaped" style="--a\,fail: pass; --unparsed:var(--a\,fail)"></div>
<script>
'use strict';
@@ -48,4 +49,11 @@
'bar/**/var(--foo)');
}, 'attributeStyleMap round-trips correctly, though the comment is gone');
+test(() => {
+ let specified = customEscaped.attributeStyleMap.get("--unparsed");
+ assert_equals(getComputedStyle(customEscaped).getPropertyValue("--unparsed"), "pass");
+ customEscaped.style.setProperty("--unparsed", specified.toString());
+ assert_equals(getComputedStyle(customEscaped).getPropertyValue("--unparsed"), "pass");
+}, 'String serialization of CSSUnparsedValue should roundtrip');
+
</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.html b/third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.html
index 6e9b1a0..691dfb0 100644
--- a/third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.html
+++ b/third_party/blink/web_tests/external/wpt/css/css-typed-om/stylevalue-serialization/cssUnparsedValue.html
@@ -8,6 +8,7 @@
<body>
<div id="tag"></div>
<div id="elem" style="color-scheme: bar/*comment*/var(--foo)"></div>
+<div id="customEscaped" style="--a\,fail: pass; --unparsed:var(--a\,fail)"></div>
<script>
'use strict';
@@ -48,4 +49,11 @@
'bar/**/var(--foo)');
}, 'attributeStyleMap round-trips correctly, though the comment is gone');
+test(() => {
+ let specified = customEscaped.attributeStyleMap.get("--unparsed");
+ assert_equals(getComputedStyle(customEscaped).getPropertyValue("--unparsed"), "pass");
+ customEscaped.style.setProperty("--unparsed", specified.toString());
+ assert_equals(getComputedStyle(customEscaped).getPropertyValue("--unparsed"), "pass");
+}, 'String serialization of CSSUnparsedValue should roundtrip');
+
</script>
Original Bug Report
MXSS in CSSUnparsedValue serialization due to missing custom-property name escaping
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 Mutation XSS (mXSS) vulnerability exists in the CSS Typed OM serialization of custom-property variable names. When CSSUnparsedValue.toString() serializes a CSSVariableReferenceValue segment, it emits the decoded identifier name verbatim without performing proper escaping (such as the CSSOM ‘serialize an identifier’ algorithm). This allows unescaped HTML metacharacters to survive in the serialized output, potentially leading to script execution if re-injected into style blocks.
Affected files:
third_party/blink/renderer/core/css/cssom/css_unparsed_value.ccthird_party/blink/renderer/core/css/cssom/css_style_variable_reference_value.cc
Estimated timestamp from git blame: 2017-12-06
Root Cause
In third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc, the AppendUnparsedString method serializes CSSVariableReferenceValue segments by appending the custom-property variable name raw to the string builder:
case V8CSSUnparsedSegment::ContentType::kCSSVariableReferenceValue: {
const auto* reference_value =
segments_[i]->GetAsCSSVariableReferenceValue();
builder.Append("var(");
builder.Append(reference_value->variable()); // Missing SerializeIdentifier()
...
During parsing of CSS, any CSS escapes inside custom-property identifiers (e.g., \3c for <) are decoded into their literal representation. Thus, the variable name stored in CSSStyleVariableReferenceValue::variable_ can contain literal HTML metacharacters such as < or >. Additionally, the JS constructor for CSSVariableReferenceValue only performs a basic starts_with("--") check on the variable name, allowing developers/attackers to directly supply names with arbitrary characters.
When CSSUnparsedValue.toString() is called, it serializes this raw variable name without utilizing the standard CSSOM SerializeIdentifier algorithm, allowing these metacharacters to be output in raw form.
Potential Trigger Path
Note: These are suggested/potential steps; our tooling agent does not yet have the ability to dynamically run or execute code.
- An attacker is able to inject a CSS style containing escaped sequences inside a custom property name, or construct a
CSSVariableReferenceValuedirectly via the JavaScript API:new CSSVariableReferenceValue("--a,</style><script>alert(1)</script>") - A web application or sanitizer reads the custom property using CSS Typed OM:
const val = element.attributeStyleMap.get('--x'); - The application serializes the value back to a string:
const serialized = val.toString();This outputs:"var(--a,</style><script>alert/**/(1)</script>)" - If the application or sanitizer injects this serialized string back into a
<style>block in the DOM, the HTML parser parses</style>, transitions out of the RAWTEXT style state, parses the<script>element, and executes the payload, leading to potential Mutation XSS (mXSS).
Suggested Fix
- In
third_party/blink/renderer/core/css/cssom/css_unparsed_value.cc, replace:builder.Append(reference_value->variable());with:SerializeIdentifier(reference_value->variable(), builder); - In
third_party/blink/renderer/core/css/cssom/css_style_variable_reference_value.cc, tighten the validation inCSSStyleVariableReferenceValue::CreateandsetVariableto verify that the variable name conforms to a valid<dashed-ident>token.
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.