Fix unquoted and invalid url() handling - #238
Merged
FlorianRappl merged 2 commits intoSep 5, 2026
Merged
Conversation
CssTokenizer.ContentFrom re-scans the raw source to recover a declaration
value or an at-rule prelude, and breaks at the first ';', '{' or '}'. It
special-cased quoted strings but knew nothing about url tokens, where all
three characters are legal content.
As a result "url(data:image/svg+xml;base64,...)" was cut at the first
semicolon and became url("data:image/svg+xml"); the remainder failed to
re-tokenize as a declaration and was dropped, so a round-trip through
CssText silently destroyed the asset. Unquoted data URIs are emitted by
every major bundler, so this affected many real stylesheets. Via the
GetArgument path the same flaw discarded whole rules: a @supports
condition containing url(a;b) lost its entire rule.
Teach ContentFrom about url tokens: on an ident "url" immediately followed
by '(', consume through the matching unescaped ')' before the break check
resumes. Behaviour was validated against Chrome's CSSOM over ~40 inputs,
which pinned down three subtleties:
- The ident must be immediately followed by '(' - "myurl(", "-url(" and
"url\t(" are ordinary function tokens where ';' does terminate the
declaration, so a plain substring match would introduce a new bug.
- url( followed by a quote is a function token, not a url token: the
string wins and a ')' inside it does not close the url. The scan skips
whitespace after '(' and defers to the existing string handling.
- Bad-url cases such as url(a b) still consume through the matching ')',
so a single "consume to unescaped ')'" rule extracts the correct span
in every case.
Escapes inside the url are honoured, so url(a\;b) also parses correctly
where it previously produced url("a\\").
The remaining divergences from Chrome (url(a b), url(a(b), url()) live in
UrlUQ/UrlBad and are unchanged by this commit.
My previous commit fixed where a url token *ends*. This fixes what happens
when one is invalid, which was a separate defect with the same symptom
class: values that browsers reject were being accepted as garbage.
Three divergences from Chrome, all rooted in the fact that a bad url had
no way to be reported as a failure:
CssUriParser.Bad() returned a CssUrlValue built from whatever characters
it had scanned past, so an invalid url produced a plausible-looking but
wrong value instead of failing. url(a b) became url("ab") and
url(a(b) became url("a(b)"); browsers drop the declaration in both cases.
Bad() now returns null and ParseUri rewinds the source, so the url() is
seen as unparsed rather than as absent - rewinding matters, because merely
consuming the bad url let "background: url(a b) red" silently re-parse as
"background: red" instead of being dropped.
ParseUri did not consume the ')' of an empty url(), leaving the source
mid-value so the declaration was rejected. url() is valid and means the
empty URL, so it now parses as url("").
CssTokenizer.NewUrl accepted a "bad" parameter and ignored it, so no
bad-url token could ever exist at the sheet level and UrlBad's scanned-over
characters became the url's content. "@import url(a b)" imported the
garbage href "a b)". A BadUrl token type now carries the distinction, and
UrlBad discards the remnants it consumes. Per the spec, EOF ends a url
token rather than invalidating it, so the two EOF paths that flagged bad
no longer do - "@import url(abc" still imports "abc".
Consequences at the rule level, matching Chrome:
- @import with a bad url is dropped instead of importing a garbage href.
- @namespace with a bad url is dropped. Fixing this forced a decision on
the string form, since one condition governs both: @namespace accepted
only a url token, so "@namespace x "http://foo"" silently produced an
empty namespace URI. It now accepts a string as the spec requires.
Behaviour was verified against Chrome's CSSOM over the full 30-case matrix
(both fixes together); no divergence remains.
ParseInlineStyleWithToleratedInvalidValueShouldReturnThatValue asserted the
old lenient recovery for url(javascript:alert(1)) - an unquoted url with a
'(' in it, i.e. exactly the url(a(b) case. Chrome drops that declaration,
so the test now documents that, and a companion test covers the quoted form
which is valid and still round-trips. The tolerance it relied on came from
Bad(), not from IsIncludingUnknownDeclarations, which only governs unknown
property names.
Contributor
|
Is this final? |
FlorianRappl
marked this pull request as ready for review
September 5, 2026 12:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Types of Changes
Prerequisites
Please make sure you can check the following two boxes:
Contribution Type
What types of changes does your code introduce? Put an
xin all the boxes that apply:Description
Two commits fixing
url()handling. The first fixes where a url token ends; the second fixes what happens when one is invalid. Both were found from the same symptom — stylesheets silently getting a wrong value instead of the right one or none at all.Behaviour throughout was validated against Chrome's CSSOM rather than from the spec alone, over a 30-case matrix. After both commits there is no remaining divergence in that matrix.
1. Unquoted
url()values were truncated at;,{or}CssTokenizer.ContentFromre-scans the raw source to recover a declaration value or an at-rule prelude, and breaks at the first;,{or}. It special-cased quoted strings but knew nothing about url tokens, where all three characters are legal content.parsed to
url("data:image/svg+xml")— cut at the first semicolon. The remainder failed to re-tokenize as a declaration and was silently dropped, so a round-trip throughCssTextdestroyed the asset. Unquoted data URIs are emitted by every major bundler for icon sprites and inlined SVG, so this affects a lot of real stylesheets.The existing test
CssSheetWithDataUrlAsBackgroundImagemissed it because it uses the quoted form, which took the string branch and round-tripped fine.Through
GetArgumentthe same flaw discarded whole rules — a@supportscondition containingurl(a;b)lost its entire rule. Escaped separators were mangled too:url(a\;b)producedurl("a\\").@importwas not affected;CreateImporttakes the href straight off the token stream.Three subtleties from the browser shaped the fix:
(.myurl(,-url(,local-url(andurl\t(are ordinary function tokens where;does terminate the declaration. A plain substring match forurl(would have introduced a new bug; hence the ident-boundary guard.url( "a)b" )is a function token, not a url token — the quoted string wins and the)inside it does not close it. So the scan skips whitespace after(and defers to the existing string handling on a quote.), so a single "consume to unescaped)" rule extracts the correct span in every case.2. Invalid
url()values produced garbage instead of failingThe root cause was that a bad url had no way to be reported as a failure.
CssUriParser.Bad()returned aCssUrlValuebuilt from whatever characters it had scanned past, so an invalid url produced a plausible-looking but wrong value instead of failing:url(a b)url("ab")url(a(b)url("a(b)")url(javascript:alert(1))url("javascript:alert(1)")url()url("")Bad()now returnsnullandParseUrirewinds the source. The rewind matters: merely consuming the bad url letbackground: url(a b) redsilently re-parse asbackground: redinstead of being dropped.ParseUrialso never consumed the)of an emptyurl(), leaving the source mid-value so the declaration was rejected.url()is valid and means the empty URL.At the sheet level,
CssTokenizer.NewUrlaccepted abadparameter and ignored it, so no bad-url token could ever exist andUrlBad's scanned-over characters became the url's content —@import url(a b)imported the garbage hrefa b). ABadUrltoken type now carries the distinction (bothCssTokenTypeandCssTokenare internal, so this is not a public API change), andUrlBaddiscards the remnants it consumes.Per the spec, EOF ends a url token rather than invalidating it, so the two EOF paths that flagged
badno longer do —@import url(abcstill importsabc, as Chrome does.Rule-level consequences, all matching Chrome:
@importwith a bad url is dropped instead of importing a garbage href.@namespacewith a bad url is dropped. Fixing this forced a decision on the string form, since one condition governs both:@namespaceaccepted only a url token, so@namespace x "http://foo"silently produced an empty namespace URI. It now accepts a string as the spec requires. This is the one change here that is not strictly abouturl()— it was unavoidable, and it is browser-validated.