Skip to content

Fix unquoted and invalid url() handling - #238

Merged
FlorianRappl merged 2 commits into
AngleSharp:develfrom
meziantou:feature/css-unquoted-data-uris-68b99b
Sep 5, 2026
Merged

Fix unquoted and invalid url() handling#238
FlorianRappl merged 2 commits into
AngleSharp:develfrom
meziantou:feature/css-unquoted-data-uris-68b99b

Conversation

@meziantou

@meziantou meziantou commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Types of Changes

Prerequisites

Please make sure you can check the following two boxes:

  • I have read the CONTRIBUTING document
  • My code follows the code style of this project

Contribution Type

What types of changes does your code introduce? Put an x in all the boxes that apply:

  • Bug fix (non-breaking change which fixes an issue, please reference the issue id)
  • New feature (non-breaking change which adds functionality, make sure to open an associated issue first)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed

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.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.

a { background-image: url(data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=) }

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 through CssText destroyed 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 CssSheetWithDataUrlAsBackgroundImage missed it because it uses the quoted form, which took the string branch and round-tripped fine.

Through GetArgument the same flaw discarded whole rules — a @supports condition containing url(a;b) lost its entire rule. Escaped separators were mangled too: url(a\;b) produced url("a\\"). @import was not affected; CreateImport takes the href straight off the token stream.

Three subtleties from the browser shaped the fix:

  • The ident must be immediately followed by (. myurl(, -url(, local-url( and url\t( are ordinary function tokens where ; does terminate the declaration. A plain substring match for url( 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.
  • Bad-url cases still consume through the matching ), so a single "consume to unescaped )" rule extracts the correct span in every case.

2. Invalid url() values produced garbage instead of failing

The root cause was 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:

input before Chrome / now
url(a b) url("ab") declaration dropped
url(a(b) url("a(b)") declaration dropped
url(javascript:alert(1)) url("javascript:alert(1)") declaration dropped
url() declaration dropped url("")

Bad() now returns null and ParseUri rewinds the source. The rewind matters: merely consuming the bad url let background: url(a b) red silently re-parse as background: red instead of being dropped.

ParseUri also never consumed the ) of an empty url(), leaving the source mid-value so the declaration was rejected. url() is valid and means the empty URL.

At the sheet level, CssTokenizer.NewUrl accepted a bad parameter and ignored it, so no bad-url token could ever exist 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 (both CssTokenType and CssToken are internal, so this is not a public API change), 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, as Chrome does.

Rule-level consequences, all 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. This is the one change here that is not strictly about url() — it was unavoidable, and it is browser-validated.

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.
@meziantou meziantou changed the title Fix truncation of unquoted url() values containing ';', '{' or '}' Fix unquoted and invalid url() handling Sep 5, 2026
@FlorianRappl

Copy link
Copy Markdown
Contributor

Is this final?

@FlorianRappl FlorianRappl added this to the v1.1.0 milestone Sep 5, 2026
@FlorianRappl
FlorianRappl marked this pull request as ready for review September 5, 2026 12:11

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@FlorianRappl
FlorianRappl merged commit b1a0767 into AngleSharp:devel Sep 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants