Skip to content

Match CSS url() quoting rules when rewriting creative styles - #1106

Draft
prk-Jr wants to merge 7 commits into
mainfrom
fix/creative-parser-bounds
Draft

Match CSS url() quoting rules when rewriting creative styles#1106
prk-Jr wants to merge 7 commits into
mainfrom
fix/creative-parser-bounds

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • The CSS url() rewriter parsed quoting by position rather than by matching delimiters, so it disagreed with how a browser reads the same declaration. Values were rewritten in ways the browser would not, and some were left unrewritten.
  • A quoted CSS string may legally contain ). The rewriter ended the value at the first ) after url(, which truncated such values and left the intended URL unproxied.
  • split_srcset_candidates re-derived per-candidate facts from the whole candidate prefix at each comma. Those facts belong to the candidate, so they are now tracked as the scan advances.

Changes

File Change
crates/trusted-server-core/src/creative.rs rewrite_style_urls: treat a value as quoted only when a matching closing quote is present; locate the closing paren after the closing quote so a quoted value keeps an inner )
crates/trusted-server-core/src/creative.rs New css_string_end helper: finds the quote that closes a CSS string, honoring backslash escapes and treating a raw newline as ending the string
crates/trusted-server-core/src/creative.rs split_srcset_candidates: derive candidate scheme and whitespace state as the scan advances instead of from the candidate prefix at each comma; corrected the doc note, which described behavior the function did not have
crates/trusted-server-core/src/creative.rs 8 tests covering quoted, unquoted, unterminated, mismatched, escaped, and multi-comma data: cases

Behavior

Input Rewritten as
url("https://cdn.example/a)b.png") whole value proxied, inner paren percent-encoded
url("https://cdn.example/plain.png") proxied — unchanged from before
url('/local/a.png") left as-is; delimiters do not match, so the extent is not guessed
url("https://cdn.example/a + newline + b) left as-is; a newline has already ended the string
srcset="data:...;base64,,,,, 1x, /b.png 2x" unchanged grouping — pinned by test

Unquoted values still end at the first ), matching the CSS url-token rule.

Verification against production CSS

Captured the 18 unique stylesheets served through a local fastly compute serve session and ran both the main and branch versions of rewrite_css_body over the identical corpus. Output is byte-identical on all 18, and both leave every file unmodified — the values there are data: URIs, which to_abs declines. No change to real traffic.

Closes

Closes #1114

Test plan

  • cargo test-fastly && cargo test-axum (also test-cloudflare, test-spin)
  • cargo clippy-fastly && cargo clippy-axum (also clippy-cloudflare, clippy-cloudflare-wasm, clippy-spin-native, clippy-spin-wasm, trusted-server-cli)
  • cargo fmt --all -- --check
  • JS tests: 893 passed
  • JS format
  • Docs format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Cross-adapter parity suite: 13 passed
  • Other: differential run of both parser versions over live production CSS (above)

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses tracing macros (not println!) — no logging added
  • New code has tests
  • No secrets or credentials committed

Treat a url() value as quoted only when a matching closing quote is
present, and end a quoted value at that quote rather than at the first
paren, so a value is rewritten the way a browser reads it.

Derive srcset candidate state as the scan advances rather than from the
candidate prefix at each comma.
@prk-Jr prk-Jr self-assigned this Sep 1, 2026
@prk-Jr
prk-Jr marked this pull request as draft September 1, 2026 15:30
The rewriter does not resolve CSS escapes, so a value carrying a backslash
cannot be mapped to the resource the page will actually request; proxying
the raw bytes points somewhere else. A raw newline, which preprocessing also
produces from a carriage return or a form feed, makes the value a bad string
the browser discards, so rewriting it proxies a URL that is never fetched.
Both are now passed through untouched.

Also fold an escaped CRLF into a single escaped newline when locating the end
of a quoted string, and end the string at a form feed, so the extent matches
what preprocessing produces.
Inserting the resolvability helper above css_string_end left that function's
doc comment attached to the new helper.
@prk-Jr

prk-Jr commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Closes #1114

prk-Jr and others added 4 commits September 2, 2026 13:40
The scan bounded a value by the next quote and paren, so a span could fuse
across declarations, a missing paren abandoned the rest of the input, an
escape became a URL nobody requests, and a value ending at input was skipped.
It also stepped back from a computed index, which slices a multi-byte
character and aborts the guest. Reading with a tokenizer supplies both the
extent and the resolved value, and leaves a malformed value — which a browser
discards anyway — on its original bytes.

Cover the other references a browser fetches: src(), a bare string candidate
in image-set(), and an @import prelude, which takes one URL and reads later
strings as media queries. A string counts as a URL only in those places, so
font-family and content keep theirs, and an @import is a rule only where a
top-level rule may start — the same token is data inside a declaration, in
another prelude, and in a style attribute, which is not a stylesheet.

The walk recurses per scope through upstream-supplied CSS, so it is bounded,
and CSS past the bound is rejected rather than passed through below it.
cssparser already built here as a transitive dependency.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Creative CSS and srcset rewriters mishandle malformed values

1 participant