Skip to content

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

Open
prk-Jr wants to merge 13 commits into
mainfrom
fix/creative-parser-bounds
Open

Match CSS url() quoting rules when rewriting creative styles#1106
prk-Jr wants to merge 13 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 decided the extent of a value by scanning for quote and paren positions. That is not how a browser reads a declaration, so some values were rewritten in ways a browser would not, and others were left unrewritten — most visibly a quoted value containing ), which the scanner truncated at the first ) after url(, leaving the intended URL unproxied.
  • The scanner is replaced by a token-level walk of the CSS grammar (cssparser), so the extent of a value and the resolution of its escapes come from the same rules a browser applies. The walk is property-agnostic: it rewrites URL references wherever they appear rather than from an enumerated list of URL-bearing properties, and it leaves fragment-only references such as filter: url(#blur) alone.
  • 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
Cargo.toml, crates/trusted-server-core/Cargo.toml Add cssparser as a direct dependency. It is already in the lockfile via lol_html, so no new package is vendored
crates/trusted-server-core/src/creative.rs rewrite_style_urls: replace positional quote scanning with a cssparser grammar walk. Escapes are already resolved when the value is read, and a malformed value — which the tokenizer reports as a bad URL or bad string, exactly what a browser discards — is left untouched rather than guessed at
crates/trusted-server-core/src/creative.rs Coverage the walk adds: src() alongside url(), bare-string candidates in image-set(), @import preludes in both the url() and bare-string forms, and var() / env() fallbacks, which are substituted in place
crates/trusted-server-core/src/creative.rs A rewritten reference keeps the shape it was read in — url() as url(), src() as src(), a bare string as a bare string — because the forms are not interchangeable to a browser. Only the value inside is replaced, and it is re-quoted; anything not rewritten keeps its original bytes
crates/trusted-server-core/src/creative.rs New MAX_CSS_NESTING_DEPTH: the CSS is supplied by the upstream creative, and a stack overflow aborts the guest, so the recursion the walk performs is bounded. The bound counts scopes the walk recurses into, not how a URL is spelled, so url(x) and url("x") are admitted at the same depth. Nesting past it discards the whole stylesheet rather than passing the deeper bytes through, which would turn the bound into a way around the rewrite
crates/trusted-server-core/src/creative.rs rewrite_css_body returns Result. A stylesheet the depth bound refuses previously left the CSS proxy path serving an empty 200, indistinguishable from a stylesheet the origin legitimately served empty. It now reports the refusal so finalize_proxied_response sets a status, matching the oversized-body path. The markup path still drops only the offending <style> block or attribute, and the log names which
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 Tests for quoted, unquoted, unterminated, mismatched and escaped values; src(), image-set(), @import and fallback coverage; the depth boundary in every spelling of a URL; and the multi-comma data: srcset case

Behavior

Input Rewritten as
url("https://cdn.example/a)b.png") whole value proxied, inner paren percent-encoded
url("https://t.example/\70 ixel.gif") proxied — the escape is resolved before the decision
src("https://cdn.example/a.woff2") proxied, re-emitted as src()
image-set("https://cdn.example/a.png" 1x) bare string proxied, still a bare string
@import "https://cdn.example/x.css"; proxied
image-set(var(--c, "https://cdn.example/a.png") 1x) fallback proxied where it sits
--c:"https://cdn.example/a.png" used as image-set(var(--c) 1x) untouched — resolving one declaration against another is the cascade's job, not a rewriter's
filter:url(#blur) untouched — a fragment is not a fetch
url('/local/a.png") untouched; the tokenizer reports a bad string, as a browser would
url("https://cdn.example/a + newline + b) untouched; a newline has already ended the string
srcset="data:...;base64,,,,, 1x, /b.png 2x" unchanged grouping — pinned by test
CSS nested past MAX_CSS_NESTING_DEPTH stylesheet discarded; the CSS proxy path answers with a status rather than an empty 200

Verification against production CSS

Ran rewrite_css_body from main and from this branch over an identical corpus of 51 real stylesheets (1.75 MB): 20 captured from the configured publisher origin, plus 31 widely-used public stylesheets from fonts.googleapis.com, cdnjs.cloudflare.com and cdn.jsdelivr.net. The public ones are there because the publisher's own bundles carry almost no url() at all, so a corpus limited to them cannot exercise the rewrite at all — which is what the earlier, smaller run of this check ended up showing.

Result Count
Byte-identical output, main vs branch 46 / 51
Differing output 5 / 51
Differences that are anything other than quoting 0
Files where the set of proxied targets differs 0 / 51
Files rejected by the depth bound 0 / 51

The 5 differing files differ only in that this branch emits url("…") where main emitted url(…); the value inside is identical, and quoting is the documented normalization. Across the 6 files that contain absolute references, both versions proxy the same 183 targets — none newly rewritten, none newly missed. That includes a real @import url("https://…") and 165 unquoted absolute url() references.

Corpus coverage, for what this does and does not attest to: 8,258 var(, 165 unquoted absolute url(, 63 quoted url(", 2 url(', 2 protocol-relative url(//, 1 @import. No stylesheet in the sample used image-set() or src(), so those paths rest on unit tests rather than on this differential.

Closes

Closes #1114

Test plan

  • cargo test-fastly (2302 passed), cargo test-axum, cargo test-cloudflare, cargo test-spin
  • cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm
  • cargo fmt --all -- --check
  • JS tests: 893 passed
  • JS format
  • Docs format
  • Cross-adapter parity suite: 13 passed
  • Other: differential run of main and branch over a 51-file production CSS corpus (above)

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses log macros (not println!)
  • 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 7 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.
A rewritten value went out as url() whatever it arrived as. For src() that
changes what the browser does rather than where it points: an engine that
ignores src() leaves the declaration inert, so emitting url() starts a request
the origin never made. Keep the name and proxy the value.

Read a var() fallback in the context around it, so a candidate written
image-set(var(--c, "https://…") 1x) is proxied like the plain string it
becomes. The propagation is deliberately narrow: substitution applies to
declaration values, so the same fallback in a content value or an @import
prelude stays untouched. A URL assembled from a separate custom-property
declaration is left alone and documented — pairing the two is the cascade's
work, not a rewriter's.
Bare-string references still went out wrapped in url(). That is valid in the
places they are read, so nothing broke, but it is not valid once the same
candidate is substituted into a src() argument, which has to stay a string.
Re-emit each reference in the shape it arrived in and replace only the value.

That makes the remaining src() gap fixable: src() takes a normal value list,
so a var() there is substituted and its fallback is the string the engine ends
up with. Walk it and rewrite the fallback where it sits, leaving both calls
intact. url() is deliberately excluded, since an engine does not substitute
inside it and a fallback there is never requested.

The entry-point note claimed everything was re-emitted as url(), which the
earlier src() change had already made untrue.
Both resolve to their fallback when the name is not set, so a string written there is a string the engine ends up with. Only var() was followed, which left image-set(env(--x, "https://...")) unproxied - and that is a supported feature reached by an unrecognised name, which is the case the fallback exists for, not a future one. Follow both, and pin that this reaches no further: a string in a gradient nested inside image-set is still not a URL.
@prk-Jr
prk-Jr marked this pull request as ready for review September 3, 2026 06:01
@prk-Jr prk-Jr added this to the 202609 milestone Sep 3, 2026

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

This is a larger change than the description suggests: commits from a8d4e099e onward replace the hand-rolled CSS scanner with a cssparser-based grammar walk, adding url()/src(), image-set() bare-string candidates, @import preludes, and var()/env() fallback following. The token-level, property-agnostic design is the right shape for this problem — it covers URL-bearing properties nobody had to enumerate, while correctly leaving fragment-only references like filter:url(#blur) alone.

Two findings below. The depth cap admits a different nesting level depending on whether a URL is quoted, and the depth rejection is operationally invisible on the wire.

Both are prose-only: the wrench fix restructures a match across three arms and needs a companion test change, so it can't be expressed as a single contiguous suggestion.

Verified locally against the PR head: cargo fmt --all -- --check, clippy-fastly, test-fastly (2301 passed), test-axum, check-cloudflare, check-spin, and the cross-adapter parity suite (13 passed) all pass.

Three things I checked and am explicitly not raising, since each looked like a finding and turned out not to be:

  • @import layer(base) "url.css" looked like a missed egress leak, but the CSS grammar requires the URL first — that prefix form is invalid and browsers do not fetch it.
  • & becoming &amp; in rewritten <style> output is real, but git show origin/main confirms the handler is byte-identical to main. Pre-existing, not this PR.
  • The cssparser dependency is free: cssparser 0.36.0 is already in main's lockfile via lol_html (Cargo.lock:2911). No new packages, and deleting the hand-rolled scanner makes the shipped WASM code sections roughly 19.7 KB smaller.

I also probed parse-error recovery specifically (bad-url tokens, unterminated strings, stray ), unmatched }, embedded NUL, CDO/CDC) and could not construct an input where a URL after the error point escapes rewriting.

Blocking

🔧 wrench

  • Depth cap rejects at a different depth depending on whether the URL is quoted — see inline at crates/trusted-server-core/src/creative.rs:93

❓ question

  • Blanking the whole stylesheet is invisible to the client — see inline at crates/trusted-server-core/src/creative.rs:159

Non-blocking

📝 note

  • PR description no longer matches the change — see the Cross-cutting section below

Cross-cutting / body-level findings

  • 📝 PR description no longer matches the change — The Summary and Changes tables describe a css_string_end helper and positional quote-scanning, which commit a8d4e099e deleted. The "Verification against production CSS" section describes a differential run over a parser version that is no longer what ships, so that evidence no longer covers the code under review. Worth refreshing before merge so the squashed commit message describes what actually landed — and worth re-running the production-CSS differential against the grammar walk, since that check is genuinely valuable and currently attests to superseded code.

CI Status

At the time of review, checks on 441c831 were still running (the head is a fresh merge commit). No failures observed; the findings above are independent of CI.

  • CodeQL: SKIPPED
  • Analyze (actions): PASS
  • Analyze (rust): PENDING
  • Analyze (javascript-typescript): PENDING
  • cargo test: PENDING (required)
  • cargo test (axum native): PENDING
  • cargo test (ts CLI, native): PENDING
  • cargo test (cross-adapter parity): PENDING
  • cargo check (cloudflare native + wasm32-unknown-unknown): PENDING
  • cargo check/build/test (spin native + wasm32-wasip1): PENDING
  • cargo fmt: PENDING (required)
  • format-typescript: PENDING (required)
  • format-docs: PENDING (required)
  • vitest: PENDING
  • prepare integration artifacts: PENDING

Comment thread crates/trusted-server-core/src/creative.rs
Comment thread crates/trusted-server-core/src/creative.rs Outdated
Reading the single string argument of `url()` or `src()` opened a parser
scope, so `url("https://…")` was charged a nesting level that the
identical `url(https://…)` was not. At exactly the cap that decided
whether the whole stylesheet survived, which is not a distinction the
constant ever claimed to make. That grammar is terminal and costs no
recursion, so it is no longer charged, and every recursion the walk makes
now runs through one `descend` that no arm can bypass. A form that does
open a scope, such as `image-set()`, still costs a level, and the bound
now follows the grammar rather than how a URL is spelled.

A refused stylesheet on the CSS proxy path was returned as an empty 200,
indistinguishable from a stylesheet the origin legitimately served empty
and attributable only from an edge-side log. `rewrite_css_body` now
reports the refusal to its caller, so the response carries a status the
way the oversized-body path already does. Markup still drops only the
offending `<style>` block or attribute, since the rest of the document is
rewritten either way, and the log names which one it dropped.
@prk-Jr

prk-Jr commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Both non-blocking items addressed.

PR description — rewritten. It described the css_string_end helper and positional quote scanning, both deleted in a8d4e09. It now describes the grammar walk, the coverage it adds (src(), image-set(), @import, var()/env() fallbacks), the shape-preserving re-emit, the depth bound, and the two fixes from this review round.

Production-CSS differential — re-run against the grammar walk, and widened, because the old corpus could not have caught anything. The publisher'''s own Next.js bundles carry 2 url() references across 20 files and 0 absolute ones, so a corpus limited to them exercises no rewriting in either version. I kept those 20 and added 31 widely-used public stylesheets (fonts.googleapis.com, cdnjs.cloudflare.com, cdn.jsdelivr.net) that actually contain absolute references.

Method: two detached worktrees at origin/main and this branch, an identical dump harness in each calling rewrite_css_body over the same 51 files (1.75 MB), outputs diffed.

Result Count
Byte-identical output 46 / 51
Differing output 5 / 51
Differences that are anything other than quoting 0
Files where the set of proxied targets differs 0 / 51
Files rejected by the depth bound 0 / 51

The 5 that differ differ only in that this branch emits url("…") where main emitted url(…) — the value inside is byte-identical, and re-quoting is the documented normalization. Across the 6 files carrying absolute references both versions proxy the same 183 targets: none newly rewritten, none newly missed. That set includes a real @import url("https://…") and 165 unquoted absolute url() references.

Stating the gap rather than leaving it implied: the corpus holds 8,258 var(, 165 unquoted absolute url(, 63 quoted url(", 2 url(', 2 protocol-relative url(// and 1 @import, but no image-set() and no src() anywhere. Those two paths rest on unit tests, not on this differential.

@prk-Jr
prk-Jr requested a review from aram356 September 4, 2026 08:15

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-reviewed PR head 60b5e992c004969836febef3efd5340ceaa85d1c.

The latest commit fixes the inconsistent nesting boundary and now reports rejected CSS responses through the proxy error path. I left two P2 findings inline.

Non-blocking documentation fix: crates/trusted-server-core/README.md:37 still documents rewrite_css_body as returning String; it now returns Result<String, CssRewriteError>.

All checks on this revision pass.

/// Rewrites URL references inside a CSS string to the first-party proxy.
///
/// Covers every form the browser fetches: `url()` and `src()`, a bare string
/// candidate in `image-set()`, and an `@import` prelude string.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Preserve signed query separators inside <style>

When the newly covered forms reach the existing <style> handler at line 1263, ContentType::Text HTML-escapes the generated URL's &tstoken= as &amp;tstoken=. A <style> element is raw text, so the browser does not decode that entity before CSS parsing. It requests a URL with an amp;tstoken parameter, and /first-party/proxy rejects it for missing tstoken.

This PR makes the problem affect bare image-set() candidates, src(), and bare @import strings that previously remained direct. Please replace the style text without HTML body escaping and add a rewrite_creative_html test asserting that the result contains &tstoken= and not &amp;tstoken=.

.map_err(|e| io::Error::other(format!("Invalid UTF-8 in CSS: {e}")))?;

let rewritten = rewrite_css_body(self.settings, &css);
let rewritten = rewrite_css_body(self.settings, &css).map_err(io::Error::other)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Bound transformed CSS output

The processor caps buffered input at 10 MiB, but rewrite_css_body can expand every short URL into a much longer signed proxy URL and return an output far above that limit. Repeated newly supported image-set("https://...") candidates make a permitted stylesheet a practical WASM memory-exhaustion input.

Checking the length after rewriting would still allow the large allocation. Please enforce the limit while building CssUrlRewriter::out and return an error once the output budget is exceeded. The same bound should cover CSS rewritten inside proxied HTML, where the temporary CSS string is allocated before the outer HTML output limit runs.

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-reviewed at 60b5e992. Both findings from my previous pass are fixed, and I verified each by assertion rather than from the commit message: the depth bound now admits url(x), url("x"), url('x') and src("x") at the same level, with image-set() still costing one because it genuinely opens a scope; and a refused stylesheet now reaches finalize_proxied_response as an error, which maps to a 502 instead of an empty 200. The descend() chokepoint is a better fix than the one I proposed, since it makes the bound structural rather than something each new arm has to remember.

Not approving, because of one regression in the paths this PR newly covers.

@ChristianPavilonis raised this as P2 and I want to add a reproduction, because the framing matters: I saw the &amp; escaping on my first pass and set it aside as pre-existing on main, which was the wrong call. It is pre-existing for url(), but image-set(), src() and bare @import strings were passed through untouched before this PR, so they loaded correctly. Now they are rewritten into URLs the proxy rejects. Those three forms regress from working to broken, and that is this PR's to fix.

Blocking

🔧 wrench

  • Newly covered <style> forms emit &amp;tstoken= and fail at the proxycrates/trusted-server-core/src/creative.rs:1263, detailed below

Cross-cutting / body-level findings

  • 🔧 Newly covered <style> forms emit &amp;tstoken= and fail at the proxyContentType::Text HTML-escapes the signed URL, and this PR makes that break forms which previously worked.

ContentType::Text entity-escapes &, but a <style> element is raw text — the HTML parser does not decode entities inside it, so the CSS parser sees &amp;tstoken= literally. The browser requests …&amp;tstoken=…, /first-party/proxy sees a parameter named amp;tstoken and no tstoken, and rejects the request.

Confirming @ChristianPavilonis's P2 with a reproduction, and correcting my own earlier read of it. I saw this escaping on my first pass and set it aside as pre-existing on main. That was right for url() — which was already rewritten there and already broken this way — but wrong as a reason to leave it. image-set(), src() and bare @import strings were not rewritten before this PR, so they reached the browser as direct third-party URLs and loaded. Now they are rewritten into URLs the proxy rejects, so they regress from working to broken:

<style> content at 60b5e992 emits
image-set("https://cdn.example/a.png" 1x) &amp;tstoken= — asset fails to load
src("https://cdn.example/f.woff2") &amp;tstoken= — font fails to load
@import "https://cdn.example/x.css"; &amp;tstoken= — stylesheet fails to load

ContentType::Html inserts the replacement without entity-escaping, which is the correct content model for a raw-text element:

                text!("style", |t| {
                    let s = t.as_str();
                    let rewritten = rewrite_style_urls(settings, s, base_origin);
                    if rewritten != s {
                        t.replace(&rewritten, ContentType::Html);
                    }
                    Ok(())
                }),

Verified in a scratch worktree at this head: with that one-line change, all four forms above emit a raw &tstoken= and none emit &amp;tstoken=, and the full cargo test-fastly suite still passes (2302 tests, 0 failures).

On the injection question that ContentType::Html naturally raises — I checked, and it does not open one here. A </style> sequence inside a CSS string terminates the element identically before and after this change, because the HTML tokenizer ends raw text at that literal regardless of how the replacement was inserted; I confirmed the escaping form is byte-identical in a case the rewriter leaves untouched. Guarding creative markup against that is sanitize_creative_html's job, and it strips <style> outright.

Worth pairing with a rewrite_creative_html test asserting the output contains &tstoken= and not &amp;tstoken= — no current test covers the <style> path's query separators, which is why CI is green on this.

  • 📝 Output growth bound (@ChristianPavilonis's second P2) not assessed here — I did not evaluate it, so nothing in this review should be read as clearing it. It is plausible on its face: every rewritten reference expands a short URL into a longer signed one, so a stylesheet that passes the 10 MiB input cap can exceed it after rewriting. Worth a reply from the author either way.

CI Status

All checks pass on 60b5e992. Locally against the same commit: cargo fmt --all -- --check, clippy-fastly, test-fastly (2302 passed), test-axum, check-cloudflare, check-spin, and the cross-adapter parity suite (13 passed) are all green. The regression below is not caught by any existing test, which is part of the finding.

  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (rust): PASS
  • CodeQL: PASS
  • browser integration tests: PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo fmt: PASS (required)
  • cargo test: PASS (required)
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native): PASS
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS

element!("[style]", |el| {
if let Some(st) = el.get_attribute("style") {
let rewritten = rewrite_style_urls(settings, &st, base_origin);
let rewritten = rewrite_style_attribute_urls(settings, &st, base_origin);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 wrench — Newly covered <style> forms emit &amp;tstoken= and are rejected by the proxy.

(Anchored here because the line this is about — the <style> handler's t.replace(&rewritten, ContentType::Text) at creative.rs:1263 — sits outside this diff's hunks and GitHub rejects a comment there with "line could not be resolved". This line is its sibling: the attribute handler that calls the same rewriter, and the nearest resolvable anchor.)

ContentType::Text entity-escapes &, but a <style> element is raw text — the HTML parser does not decode entities inside it, so the CSS parser sees &amp;tstoken= literally. The browser requests …&amp;tstoken=…, /first-party/proxy sees a parameter named amp;tstoken and no tstoken, and rejects it.

Confirming @ChristianPavilonis's P2 with a reproduction, and correcting my own earlier read. I saw this escaping on my first pass and set it aside as pre-existing on main. That was right for url() — already rewritten there, already broken this way — but wrong as a reason to drop it. image-set(), src() and bare @import strings were not rewritten before this PR, so they reached the browser direct and loaded. Now they are rewritten into URLs the proxy rejects:

<style> content at 60b5e992 emits
image-set("https://cdn.example/a.png" 1x) &amp;tstoken= — asset fails to load
src("https://cdn.example/f.woff2") &amp;tstoken= — font fails to load
@import "https://cdn.example/x.css"; &amp;tstoken= — stylesheet fails to load

Those three regress from working to broken, which is what makes this the PR's to fix rather than inherited.

ContentType::Html inserts the replacement without entity-escaping, the correct content model for a raw-text element. At creative.rs:1263:

                text!("style", |t| {
                    let s = t.as_str();
                    let rewritten = rewrite_style_urls(settings, s, base_origin);
                    if rewritten != s {
                        t.replace(&rewritten, ContentType::Html);
                    }
                    Ok(())
                }),

Verified in a scratch worktree at this head: with that one-line change all four forms emit a raw &tstoken=, none emit &amp;tstoken=, and cargo test-fastly still passes (2302 tests, 0 failures).

On the injection question ContentType::Html naturally raises — it does not open one here. A </style> inside a CSS string terminates the element identically before and after, because the HTML tokenizer ends raw text at that literal however the replacement was inserted; I confirmed the output is byte-identical in a case the rewriter leaves untouched. Guarding creative markup against that is sanitize_creative_html's job, and it strips <style> outright.

Worth pairing with a rewrite_creative_html test asserting the output contains &tstoken= and not &amp;tstoken= — no current test covers this path's query separators, which is why CI is green on it.

Apply manually — can't be offered as a one-click suggestion, since the line it would replace is the unresolvable 1263 rather than this anchor.

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

3 participants