b4e7e8bd27 Align with disallowing <url> type in attr()
Triage note: The attr-security WPTs flip to PASS; disallowing typed <url> from attr() blocks an attribute-driven URL-resolution/exfiltration bypass.
Contents
The bug at a glance
This closes an attribute-tainting bypass in CSS attr(): a typed attr(data-foo type(<url>)) let attacker-controlled attribute text become a live, non-tainted url(), defeating the attr-taint mechanism whose entire purpose is to stop attribute values from being turned into resource-fetching URLs. The consequence is a same-page data exfiltration / CSRF-style resource-load primitive (background-image or custom property resolving to an attacker-chosen URL) that should have been neutralized by tainting, so it is a privacy/SSRF-adjacent boundary bug rather than memory corruption. It is rated high because attr-tainting is a deliberate security control and the bypass is directly demonstrated by WPT security tests flipping FAIL to PASS. The blast radius is limited to what CSS URL loading can reach and requires the page to consume the tainted attribute in a URL context.
CSS attr() deliberately ’taints’ any value pulled from an element attribute so it cannot silently become a URL and fetch a resource. The bug is that the typed form — attr(data-foo type(<url>)) — skipped that taint and produced a normal url(), so an attribute string the attacker controls turned into a live fetch. The fix simply makes <url> an illegal component type inside attr()’s type() syntax, per the CSSWG resolution, so the typed path can never yield a URL at all.
Root cause
SubstitutionResolver::substituteAttrFunction in StyleSubstitutionResolver.cpp implements CSS attr() substitution: it parses the requested attr type, reads the element’s attribute string, and produces a substituted CSS value. attr() values are subject to attr-tainting — a mechanism that marks values originating from attributes so they cannot be used to construct a url() that actually loads a resource; a tainted url() must instead fall back rather than fetch, preventing attribute text from becoming an exfiltration/SSRF vector.
The typed form attr(name type(<syntax>)) lets an author coerce the attribute string into a specific CSS type. The pre-patch parser accepted <url> as a legal component inside the type() production: consumeType produced a syntax whose definition could contain a URL component, and the resolver would then parse the attribute string as a <url> and emit a genuine url(). Crucially this typed path did not carry the taint that the untyped string path enforced — so attr(data-foo type(<url>)) with data-foo set to url(https://attacker/…) resolved to a live, loadable url(), exactly the outcome tainting exists to prevent. The WPT deltas make this concrete: ‘Returned url() is attr-tainted, typed attr()’ and attr-security’s ‘–x: attr(data-foo type(<url>))’ with data-foo=“url(https://does-not-exist.test/404.png)" both flip from FAIL (got the live url) to PASS.
The fix implements the CSSWG resolution (csswg-drafts #5079) that <url> is invalid as a <syntax-single-component> inside attr()’s type(). After consumeType succeeds, the resolver iterates the parsed syntax components and rejects the whole attr() if any component is a URL type: for (auto& component : syntax->definition) { if (component.type == CSSCustomPropertySyntax::Type::URL) return { }; }. Returning { } fails the attr type parse so the typed url path is unreachable — the attribute can no longer be coerced into a URL, eliminating the tainting bypass at its source.
A second, related correctness change fixes unknown-unit handling. Previously an <attr-unit> whose token did not map to a known CSS unit (stringToUnitType == CSS_UNKNOWN) failed the type parse outright (return { }). The patch removes that early failure at parse time and instead, at substitution time for AttrType::Unit, calls substituteFailure() when parsedAttrType->unitType == CSSUnitType::CSS_UNKNOWN: if (attrType == AttrType::Unit && parsedAttrType->unitType == CSSUnitType::CSS_UNKNOWN) return substituteFailure();. This aligns behavior with the spec — an unknown unit triggers the attr() fallback value rather than invalidating the declaration — which is what WPT attr 125/126 assert (expected the ‘3px’ fallback, previously got the wrongly-substituted ‘784px’).
Key code
StyleSubstitutionResolver.cpp — disallow <url> and fall back on unknown units
auto syntax = CSSCustomPropertySyntax::consumeType(range);
if (!syntax)
return { };
// https://drafts.csswg.org/css-values-5/#typedef-attr-type
// "For this purpose, <url> is invalid as a <syntax-single-component>."
for (auto& component : syntax->definition) {
if (component.type == CSSCustomPropertySyntax::Type::URL)
return { };
}
return AttrTypeResult { AttrType::Syntax, { }, WTF::move(*syntax) };
// ...
// "If the <attr-unit> does not match a known CSS unit, it triggers fallback."
if (attrType == AttrType::Unit && parsedAttrType->unitType == CSSUnitType::CSS_UNKNOWN)
return substituteFailure();
Patch walkthrough
Source/WebCore/style/StyleSubstitutionResolver.cpp— In substituteAttrFunction, after consumeType parses the type() syntax, a loop rejects the attr() (returns {}) if any syntax component has type CSSCustomPropertySyntax::Type::URL — implementing ‘disallow <url> in attr()’. Separately, the parse-time rejection of unknown units (unit == CSS_UNKNOWN -> return {}) is removed; instead the Unit/Percentage substitution branch calls substituteFailure() when parsedAttrType->unitType == CSS_UNKNOWN, so an unknown <attr-unit> triggers fallback rather than failing substitution outright.LayoutTests/imported/w3c/web-platform-tests/css/css-values/attr-security-expected.txt— ‘–x: attr(data-foo type(<url>))’ with data-foo=“url(https://does-not-exist.test/404.png)" flips FAIL->PASS: the typed attr() no longer yields a live url(), directly demonstrating the closed tainting bypass.LayoutTests/imported/w3c/web-platform-tests/css/css-mixins/function-attr-expected.txt— ‘Returned url() is attr-tainted, typed attr()’ flips FAIL->PASS, confirming the typed path is now covered by tainting semantics (by being disallowed).LayoutTests/imported/w3c/web-platform-tests/css/css-values/attr-all-types-expected.txt— attr 125/126 (unknown unit ‘xx’ with fallback 3px) and attr 171 (type(<url>)) flip FAIL->PASS, covering both the unknown-unit fallback and the <url> disallowance.
Background
CSS attr() typed substitution — The modern CSS attr() (css-values-5) can return a typed value: attr(name type(<syntax>)) parses the attribute string according to the given syntax component, and attr(name <unit>) interprets a numeric attribute as a dimension. The resolver reads the attribute text at style-resolution time and produces a CSS value, with a fallback used when parsing or the syntax fails.
attr-tainting — Because attribute values are trivially attacker/author-controllable and can carry across documents, CSS taints values that originate from attr(). A tainted value is barred from becoming a URL that actually loads a resource — a tainted url() must resolve to a non-loading state or fall back. This prevents patterns like attr(data-foo) being fed into a url() to exfiltrate data or fetch attacker-chosen resources purely from markup an attacker can inject into attribute text.
The <url> component bypass — The bug is that the typed form attr(name type(<url>)) produced a real url() that was not subject to tainting, so an attribute containing url(https://attacker/…) became a live fetch. The CSSWG resolved (csswg-drafts #5079) that <url> is simply not a permitted <syntax-single-component> inside attr()’s type(), which removes the dangerous path entirely rather than trying to retrofit tainting onto it.
CSSCustomPropertySyntax and consumeType — attr()’s type() reuses the registered-custom-property syntax grammar. consumeType parses the syntax descriptor into a definition of components, each with a Type (Length, Number, URL, etc.). The fix walks that parsed definition and rejects the attr() if any component is Type::URL, catching <url> wherever it appears in the syntax.
substituteFailure and fallback semantics — When attr() cannot produce a valid value it must invoke its fallback (the second argument) rather than making the whole declaration invalid. The unknown-unit change moves the failure from parse time (which invalidated the declaration) to substitution time via substituteFailure(), so an unrecognized <attr-unit> yields the author’s fallback value, matching the spec and the attr 125/126 tests.
Vulnerability window
- attr() typed forms shipped — WebKit implemented attr(name type(<syntax>)) including <url> as an accepted component, and parse-time rejection of unknown units.
- Bypass exists — attr(data-foo type(<url>)) yields a live, non-tainted url(), defeating attr-tainting; WPT attr-security and function-attr tests fail, showing attribute text turning into a real resource URL.
- CSSWG resolution — csswg-drafts #5079 resolves that <url> is invalid as a <syntax-single-component> in attr(), and css-values-5 specifies unknown <attr-unit> triggers fallback.
- Fix (bug 314833 / rdar://177540489) — Antti Koivisto adds a loop rejecting URL components in the parsed syntax and moves unknown-unit handling to substituteFailure().
- Validation — attr-security, function-attr, and attr-all-types WPT expectations flip FAIL->PASS for the typed-<url> and unknown-unit cases, confirming the tainting bypass is closed.
Proof of concept
No new test file is added; the PoC is embodied in the pre-existing WPT cases whose expected results flip from FAIL to PASS (attr-security ‘–x: attr(data-foo type(<url>))’, attr-all-types attr 171, and function-attr ‘Returned url() is attr-tainted, typed attr()’). The snippet above reconstructs the minimal trigger verbatim from those expectation strings: an attribute carrying a url(…) coerced via type(<url>) resolved to a live url() before the patch and is rejected after.
/* From WPT attr-security / function-attr expectations that flip FAIL->PASS.
Pre-patch: the typed attr() produced a live url(); post-patch it is invalid. */
// <div data-foo='url(https://does-not-exist.test/404.png)'></div>
// element.style: --x: attr(data-foo type(<url>));
// Pre-patch getComputedStyle(el).getPropertyValue('--x')
// === 'url("https://does-not-exist.test/404.png")' (bypass: live url, not tainted)
// Post-patch: '' (attr() invalid, no url produced)
//
// background-image: attr(data-foo type(<url>)) likewise no longer fetches the attribute-supplied URL.
Exploitation
- Inject attribute — Attacker gets attacker-controlled text into an element attribute (e.g. data-foo) — via an injection sink, a templating hole, or user-content that lands in an attribute — plus a stylesheet/style using attr(data-foo type(<url>)) in a URL-consuming property such as background-image or a custom property later used in url().
- Coerce to URL — Pre-patch, the typed attr() emits a live url() from the attribute string, bypassing attr-tainting; the engine fetches the attacker-chosen URL, giving a same-origin-context outbound request primitive (resource load, cache probing, or exfiltration of the attribute-encoded data to an attacker host).
- Limits — Impact is confined to what CSS URL loading permits (no arbitrary code execution, no memory corruption) and requires the page to consume the attribute in a URL context. It is a privacy/SSRF-adjacent tainting bypass; post-patch the typed <url> path is simply invalid, so the primitive disappears.
Detection & hunting
For defenders and SOC / detection engineers:
- type(<url>) in stylesheets/inline styles — Scan CSS (stylesheets, style attributes, CSSOM strings) for attr( … type(<url>) ) usage; legitimate use is essentially nonexistent, so its presence on unpatched engines is a strong bypass indicator worth flagging in content-inspection pipelines.
- Outbound loads whose URL matches an element attribute value — Correlate resource fetches originating from CSS with URLs that equal a data-* / arbitrary attribute value on the page; an attribute string appearing verbatim as a fetched URL suggests attr()-to-url() coercion.
Audit directions
- Other attr()/env()/var() typed paths — Review all CSS substitution resolvers that can coerce author/attribute strings into typed values for whether any type that loads resources (url, image, src()) can be produced without tainting.
- Tainting coverage across value producers — Audit where attr-taint flags are set and checked; ensure every code path that builds a CSSPrimitiveValue of URL kind from an attribute-derived string preserves taint or is disallowed.
- CSSCustomPropertySyntax::Type::URL consumers — Find every place that parses a registered-property or attr() syntax and confirm URL components are handled per the CSSWG resolution in the attr() context specifically.
- Fallback vs invalidation semantics — Check other attr()/var() failure paths for the same parse-time-vs-substitution-time discrepancy the unit fix addressed, so failures trigger fallback rather than silently mis-substituting or invalidating declarations.