Repository navigation
Conversation
Spec and implementation plan for #1234: an [auction] rewrite_clicks switch that follows rewrite_creatives when unset, so click wrapping can be controlled independently of asset rewriting. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Describe the review fixes: the extended shared-step CHANGELOG entry and the sign status change, rollback in both directions, the reworded Auction Rewrite Control docs and renderGuard note, the fuller body-less matrix fixture, the exact default-output pin, and the stronger proxied-HTML test. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Replace to_abs with normalize_creative_url and move exclusion policy to Rewrite::should_proxy_asset and Rewrite::should_wrap_click, sharing the case-insensitive proxy host matcher. Normalize exclude_domains at load. This is the shared step for #1231 and #1234. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
…when unset Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
The rewrite pass registers asset handlers only when rewrite_creatives is on and the anchor handler only when click rewriting resolves on. Base removal and TSJS injection run whenever either is on. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
…rty proxy Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
…avior Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Correct the renderGuard caveat for the sandboxed /auction renderer, list the operator docs that become false, keep a bidder <base target> while dropping its href, and warn on the public rewrite wrappers. Also fix citations, the shared CHANGELOG Fixed wording, red-step ordering, the Step 5 grep, rollout and staged rollback notes, and other precision items. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
…-creative-click-rewrite-switch Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Restore the example2.com.fake.com style case dropped in the port of test_rewrite_is_excluded, and point the should_proxy_asset docs at is_host_allowed in code text so cargo doc stops warning about a link to a private item. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
rewrite_creative_html and rewrite_inline_creative_html rewrite assets and clicks unconditionally. Document that production code must use the auction processing entry points, which honor both switches. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
The rewrite pass removed every <base>, which also dropped target="_blank" so raw links opened inside the creative iframe. Drop only the href, which is what can rebase first-party URLs, and remove a <base> left without a target as before, so default output is unchanged. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Name rewrite_clicks in the orchestration example, environment overrides and rollback note, fix the rewrite_creatives = false summary, correct the renderGuard caveat for the sandboxed renderer, and document base target handling and the inert click guard. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
List the CSS re-quoting, https: prefixing and sign error text changes in the Fixed entry, and note that a bidder base target is now kept. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
|
@prk-Jr Your review comments on the spec PRs (#1248 and #1249) are addressed in the spec branches. Those changes are merged here, along with the matching code changes: the |
Brings #1252 at 7c5eca0, including the design spec and plan from its spec/1234-creative-click-rewrite-switch base. Integration decisions: - Settings normalization runs #1252's rewrite.normalize() and keeps main's fallible image_optimizer.normalize()? from #1221. - CHANGELOG.md keeps the RC's entries and replaces the older rewrite_creatives entry with #1252's version, which defers click wrapping to rewrite_clicks.
Brings #1253 at e210f62, including the design spec and plan from its spec/1231-creative-asset-host-allowlist base. #1253 carries its own copy of #1252's rewrite_clicks commits plus the include_domains allowlist, so its conflicts with #1252 are two versions of the same change. Integration decisions: - proxy.rs, creative.rs and settings.rs take a clean three-way merge of #1253 against the RC before #1252. #1253's versions of the shared rewrite_clicks code supersede #1252's, and they also carry #1252's suffix-spoofing test case and doc-link fix. Every other file #1253 touches matches that re-merge exactly, so nothing is duplicated. - Settings normalization keeps main's fallible image_optimizer normalize()? beside rewrite.normalize(). - CHANGELOG.md keeps the RC's entries and takes #1253's exclude_domains, include_domains, rewrite_clicks and rewrite_creatives entries.
aram356
left a comment
There was a problem hiding this comment.
Summary
Splits click wrapping into [auction] rewrite_clicks and narrows rewrite_creatives to assets, with a shared URL normalizer and host policy. The core logic holds up. For every parseable URL in a 40-input edge corpus, the new normalizer produces signed proxy and click URLs byte-identical to the old to_abs path. 26 of 29 mutants against the new logic are killed, and the tree merged with current main passes clippy-fastly, test-fastly, test-axum and the CLI overlay tests. What blocks is the merge conflict with main, operator docs left contradicting the switch, and release notes that miss a default-config <base target> change.
6 of the inline comments below carry a one-click GitHub
suggestion. Use Commit suggestion (or Add suggestion to batch for several at once) to apply them. Each one was applied in a scratch tree and checked with the pinned docs prettier (3.8.1), or withcargo fmt --check,cargo clippy-fastlyand the targeted test for the Rust one. The remaining comments describe the fix in prose because it touches lines outside the diff.
Blocking
🔧 wrench
- Conflicts with
main: see the cross-cutting section below - Operator docs left contradicting the switch: see inline at
docs/guide/auction-orchestration.md:641 - Default-config
<base target>change described as no change: see inline atCHANGELOG.md:44 /first-party/signerror-string claim, missing one-time push diff: see inline atCHANGELOG.md:36<base>removal and TSJS wording: see inline attrusted-server.example.toml:267- Click-guard sentence needs the
rewrite_creativesqualifier: see inline atdocs/guide/creative-processing.md:121 - Spec status and rollout are stale: see inline at
docs/superpowers/specs/2026-10-07-1234-creative-click-rewrite-switch-design.md:9 - No test for non-http(s) schemes that carry a host: see inline at
crates/trusted-server-core/src/creative.rs:2063 - Stale prose outside the diff and smaller precision items: see the cross-cutting section below
Non-blocking
🤔 thinking
- The
/auctionclick guard ignores the kept<base target>: see inline atcrates/trusted-server-core/src/creative.rs:1288
🌱 seedling
exclude_domainsstill misses trailing-dot hosts and IDN entries: see inline atcrates/trusted-server-core/src/settings.rs:681
Cross-cutting / body-level findings
-
🔧 Conflicts with
main. GitHub reports the branch as conflicting withmain(d2f0d8c), and both conflicts are mechanical. InCHANGELOG.md, both sides add### Fixedbullets, so keep all of them. InSettings::normalize_deserialized(settings.rs), #1221 made the function returnResultand callself.image_optimizer.normalize()?;. Keep that and add this PR's call above it:self.proxy.normalize(); self.rewrite.normalize(); self.image_optimizer.normalize()?;
With that resolution, a scratch merge of
maininto this head passes:cargo fmt --all -- --check;cargo clippy-fastly;cargo test-fastly(core lib 2949 passed, all 18 new core tests included);cargo test-axum;- the CLI
config_env_overlaytests (10 passed, both newrewrite_clickstests included).
None of the 13
maincommits interact with this change semantically: -
🔧 Stale prose outside the diff. These predate this PR, but they now sit next to the new text and disagree with it:
docs/guide/creative-processing.md:704-705says*.cdn.example.comdoes not matchcdn.example.com("no subdomain").is_host_allowedmatches the apex (proxy.rs:1246-1250), this PR'srewrite_policy_matches_exclude_patterns_case_insensitivelyasserts it, andconfiguration.md:1025already says ✅. While there, note that matching is case-insensitive.docs/guide/creative-processing.md:968-970troubleshooting says "Ensure origin response includes proper<base>tag". The rewriter drops<base href>whenever it runs.crates/trusted-server-core/src/settings.rs:640-641: theexclude_domainsdoc says matching hosts "will not be proxied through first-party endpoints".should_wrap_clickuses the same list, so they are not click-wrapped either.crates/trusted-server-core/src/proxy.rs:1621-1624: the# Errorssection ofhandle_first_party_proxy_signdoes not list rejection of hosts matched byrewrite.exclude_domains.crates/trusted-server-js/lib/src/shared/origin.ts:56-57: says the server-side rewriter "strips<base>from creative markup whenever rewriting is enabled". It now drops thehref(keepingtarget) when asset or click rewriting is on.
-
🔧 Smaller precision items in operator docs:
docs/guide/configuration.md:1950-1951:- "so existing configs keep their current behavior" is followed directly by the
<base target>change, which applies to existing configs. - The
<base>sentence does not mention thatsanitize_creatives = trueremoves<base>entirely before the rewrite pass (creative.rs:898). - The same sanitizer caveat applies to the
<base>column of the Assets and clicks table (creative-processing.md:107-112) and tocreative-processing.md:129-130.
- "so existing configs keep their current behavior" is followed directly by the
docs/guide/configuration.md:2185: "links still followrewrite_clicks" reads as if links stay wrapped. With it unset they turn off too. Suggest "links are wrapped only whenrewrite_clicks = true".docs/guide/auction-orchestration.md:861-862: "rolling back also changes link wrapping whenrewrite_clickswas set" is only true when it differed fromrewrite_creativeson the auction paths, or wasfalse, which also changes proxied HTML.
CI Status
- CLAUDE.md symlink guard: 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) (macos-latest): PASS
- cargo test (ts CLI, native) (ubuntu-latest): PASS
- format-docs: PASS (required)
- format-typescript: PASS (required)
- integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- prepare integration artifacts: PASS
- vitest: PASS
| unified creative TSJS runtime exactly once, whether or not the bidder supplied a | ||
| `<body>` element. In every mode, a creative | ||
| larger than the 1 MiB per-creative cap is rejected and its `adm` is dropped. | ||
| `<body>` element. `rewrite_clicks` (unset by default, following |
There was a problem hiding this comment.
🔧 wrench: This sentence is right, but the rest of the section still describes the pre-split behavior, so it contradicts itself once rewrite_clicks is set:
- 636-640 say
rewrite_creatives"converts eligible external resource and click URLs … addsdata-tsclick… removes bidder-supplied<base>elements". Two things changed:- The anchor handler and
data-tsclickare registered only whenfeatures.clicks(creative.rs:1455-1468), which comes fromrewrites_auction_clicks(). - A
<base>withtargetis now kept (creative.rs:1288-1294).
- The anchor handler and
- 655-658 table:
- The
false/falserow ("exactly as the bidder returned it") and thetrue/falserow ("asset and click URLs remain direct") are false withrewrite_clicks = true. - The two
rewrite_creatives = truerows ("add click-guard attributes") are false withrewrite_clicks = false.
- The
- 662-664: "Disabling rewriting removes the injected creative runtime and first-party proxy/click mediation" is false with
rewrite_creatives = falseandrewrite_clicks = true. - 668 and 672: "Both settings apply…" and "HTML/CSS returned by
/first-party/proxycontinues to be rewritten independently". There are three settings now, and an explicitrewrite_clicksreaches proxied HTML (creative.rs:1140-1152). docs/guide/creative-processing.md:174-177has the same problem. "Neither setting affects HTML/CSS response rewriting under/first-party/proxy… follows both settings" contradicts that page's new line 51.
The spec's doc-update table (spec:494) lists this section (634-663) for the update.
Proposed text. Apply it manually: most of these lines are outside the diff hunks, so it can't be a one-click suggestion. For 636-643:
`rewrite_creatives` (default `true`) converts eligible external asset and
inline CSS `url(...)` URLs to signed `/first-party/proxy` paths.
`rewrite_clicks` (unset by default, following `rewrite_creatives`) wraps
`<a href>`/`<area href>` in `/first-party/click` and adds `data-tsclick`; see
[Creative Processing](/guide/creative-processing#assets-and-clicks). When
either is on, the pass drops a bidder `<base>` URL (keeping its `target`) and
injects the unified creative TSJS runtime exactly once, whether or not the
bidder supplied a `<body>` element.- Table: add a lead-in such as "Links and TSJS follow
rewrite_clicks; see Assets and clicks." Alternatively, reword the rows to talk about asset URLs only. - 662: "Disabling both
rewrite_creativesandrewrite_clicksremoves …" - 668: "All three settings apply …"
- 672: "… rewritten independently of
rewrite_creatives; only an explicitrewrite_clickschanges its link wrapping." creative-processing.md:174: "rewrite_creativesandsanitize_creativesdo not affect HTML/CSS rewriting under/first-party/proxy; an explicitrewrite_clicksdoes apply to links there. The publisher SSAT/page-bids path … follows all three settings."
| ### Added | ||
|
|
||
| - Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `<base>` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery. | ||
| - Added `[auction].rewrite_clicks` to control creative click-through wrapping (`<a href>`/`<area href>` → signed `/first-party/click`) independently of asset rewriting. Unset (the default) follows `rewrite_creatives` for `POST /auction` and SSAT/page-bids and keeps wrapping links in HTML fetched through `/first-party/proxy`, so existing configs behave as before; an explicit value applies to every path. `rewrite_creatives` now governs asset URLs only; bidder `<base>` handling and creative TSJS injection run when either setting is on. A bidder `<base target>` is now kept with its `href` dropped, instead of removing the whole element, so raw links keep opening where the bidder intended. Upgrading: deploy the binary first, then push a config that sets `rewrite_clicks`. Rolling back: remove any explicit `rewrite_clicks` (and its environment override), push the resulting config, then roll back the binary. Older binaries tie click wrapping to `rewrite_creatives` in both directions, so rolling back turns clicks back on for `rewrite_creatives = true` with `rewrite_clicks = false`, and off for `rewrite_creatives = false` with `rewrite_clicks = true`. |
There was a problem hiding this comment.
🔧 wrench: This PR changes output under the default config. <base href=… target=…> used to be removed outright (git show 182fdf45c:crates/trusted-server-core/src/creative.rs, lines 1163-1166). It is now emitted as <base target=…> on /auction, inline SSAT/page-bids and proxied HTML; I checked this in a scratch test with create_test_settings(). This bullet says "existing configs behave as before" and files the change under Added.
The suggestion below keeps the bullet and states the change plainly. Two related places need a manual edit:
- Line 23 (Unreleased Breaking entry, outside this diff) still says bidder
<base>elements "are stripped whenever rewriting is enabled". Amend it to something like "a bidder<base>loses itshrefwhenever asset or click rewriting is on (one withouttargetis removed)". - The same "unchanged" claim appears at
docs/guide/configuration.md:1950, spec :152, :341, :470 and :523, plan :5 and :22, and the PR body ("no existing config changes behavior on upgrade").
| - Added `[auction].rewrite_clicks` to control creative click-through wrapping (`<a href>`/`<area href>` → signed `/first-party/click`) independently of asset rewriting. Unset (the default) follows `rewrite_creatives` for `POST /auction` and SSAT/page-bids and keeps wrapping links in HTML fetched through `/first-party/proxy`, so existing configs behave as before; an explicit value applies to every path. `rewrite_creatives` now governs asset URLs only; bidder `<base>` handling and creative TSJS injection run when either setting is on. A bidder `<base target>` is now kept with its `href` dropped, instead of removing the whole element, so raw links keep opening where the bidder intended. Upgrading: deploy the binary first, then push a config that sets `rewrite_clicks`. Rolling back: remove any explicit `rewrite_clicks` (and its environment override), push the resulting config, then roll back the binary. Older binaries tie click wrapping to `rewrite_creatives` in both directions, so rolling back turns clicks back on for `rewrite_creatives = true` with `rewrite_clicks = false`, and off for `rewrite_creatives = false` with `rewrite_clicks = true`. | |
| - Added `[auction].rewrite_clicks` to control creative click-through wrapping (`<a href>`/`<area href>` → signed `/first-party/click`) independently of asset rewriting. Unset (the default) follows `rewrite_creatives` for `POST /auction` and SSAT/page-bids and keeps wrapping links in HTML fetched through `/first-party/proxy`, so existing configs keep their asset and link behavior; an explicit value applies to every path. `rewrite_creatives` now governs asset URLs only; bidder `<base>` handling and, on `POST /auction`, creative TSJS injection run when either setting is on. Also changed for every config with rewriting on, including the default: a bidder `<base target>` is now kept with its `href` dropped, instead of removing the whole element, so raw links keep opening where the bidder intended. Upgrading: deploy the binary first, then push a config that sets `rewrite_clicks`. Rolling back: remove any explicit `rewrite_clicks` (and its environment override), push the resulting config, then roll back the binary. Older binaries tie click wrapping to `rewrite_creatives` in both directions, so rolling back turns clicks back on for `rewrite_creatives = true` with `rewrite_clicks = false`, and off for `rewrite_creatives = false` with `rewrite_clicks = true`. |
|
|
||
| ### Fixed | ||
|
|
||
| - `rewrite.exclude_domains` matching is now case-insensitive and ignores surrounding whitespace. Entries written with uppercase letters previously never matched and now take effect, so those hosts stop being proxied and click-wrapped, and `/first-party/sign` now rejects them with `502`; it used to sign them, or return `403` when the host was also outside `proxy.allowed_domains`. Empty and bare `"*"` `exclude_domains` entries, which never matched a host, are dropped at load with a warning. Absolute `http(s)` creative URLs that cannot be parsed (for example, a host containing a space) are now left untouched; previously the trimmed value was written back re-quoted (with `https:` prepended to `//` input, so `src=" //exa mple.example/x "` became `https://exa mple.example/x`), an unparseable quoted URL in `<style>` or `style=` was re-quoted through `cssparser::serialize_string`, and a link also gained `data-tsclick`. `/first-party/sign` now answers malformed input and non-http(s) schemes with `unsupported url` instead of `invalid url` / `unsupported scheme`; both still map to `502`. Audit `exclude_domains` for mixed-case entries before upgrading. |
There was a problem hiding this comment.
🔧 wrench: Two precision fixes to this entry.
-
The
/first-party/signerror-string claim. At the merge base, non-http(s) input already answeredunsupported url:- Non-
//input went throughto_abs, which returnedNonefor any other scheme. unsupported schemewas reachable only for//input with a non-http(s) request scheme, andnormalize_scheme(http_util.rs:224-227) never produces one.
The only real change is that unparseable
http(s)input now getsunsupported urlinstead ofinvalid url. - Non-
-
The one-time push diff. Spec rollout step 5 (spec:472) notes that the first
ts config pushafter upgrading shows anexclude_domainsdiff. Settings serialize normalized, throughconfig.rs:112. Operators reading the CHANGELOG don't see that, unlike theallowed_context_keysentry below, which does warn about its one-time reorder.
| - `rewrite.exclude_domains` matching is now case-insensitive and ignores surrounding whitespace. Entries written with uppercase letters previously never matched and now take effect, so those hosts stop being proxied and click-wrapped, and `/first-party/sign` now rejects them with `502`; it used to sign them, or return `403` when the host was also outside `proxy.allowed_domains`. Empty and bare `"*"` `exclude_domains` entries, which never matched a host, are dropped at load with a warning. Absolute `http(s)` creative URLs that cannot be parsed (for example, a host containing a space) are now left untouched; previously the trimmed value was written back re-quoted (with `https:` prepended to `//` input, so `src=" //exa mple.example/x "` became `https://exa mple.example/x`), an unparseable quoted URL in `<style>` or `style=` was re-quoted through `cssparser::serialize_string`, and a link also gained `data-tsclick`. `/first-party/sign` now answers malformed input and non-http(s) schemes with `unsupported url` instead of `invalid url` / `unsupported scheme`; both still map to `502`. Audit `exclude_domains` for mixed-case entries before upgrading. | |
| - `rewrite.exclude_domains` matching is now case-insensitive and ignores surrounding whitespace. Entries written with uppercase letters previously never matched and now take effect, so those hosts stop being proxied and click-wrapped, and `/first-party/sign` now rejects them with `502`; it used to sign them, or return `403` when the host was also outside `proxy.allowed_domains`. Empty and bare `"*"` `exclude_domains` entries, which never matched a host, are dropped at load with a warning. Absolute `http(s)` creative URLs that cannot be parsed (for example, a host containing a space) are now left untouched; previously the trimmed value was written back re-quoted (with `https:` prepended to `//` input, so `src=" //exa mple.example/x "` became `https://exa mple.example/x`), an unparseable quoted URL in `<style>` or `style=` was re-quoted through `cssparser::serialize_string`, and a link also gained `data-tsclick`. `/first-party/sign` now answers unparseable `http(s)` input with `unsupported url` instead of `invalid url`; both map to `502`. The first `ts config push` after upgrading shows a one-time `exclude_domains` diff for configs with mixed-case, padded, empty or bare `"*"` entries. Audit `exclude_domains` for mixed-case entries before upgrading. |
| # true). Set false to leave asset URLs direct. Bidder <base> removal and | ||
| # creative TSJS injection run when this or rewrite_clicks is on. Sanitization |
There was a problem hiding this comment.
🔧 wrench: "Bidder removal and creative TSJS injection" reads as if both happen on every path. In fact:
- TSJS is injected only on
POST /auction; the inline path passesinject_tsjs = false(creative.rs:1049-1058). - A
<base>withtargetnow keeps it (creative.rs:1288-1294).
The same wording appears in four more places. Worth aligning them in the same pass:
crates/trusted-server-core/src/auction/README.md:111-112: "rewrite_creatives = true… removes bidder<base>elements". This also ties it torewrite_creativesalone.auction/endpoints.rs:93andauction_config_types.rs:44: "Bidder<base>removal".creative.rs:38-39(module doc) andcreative.rs:1245-1246: TSJS injection "always run[s]", but it depends oninject_tsjs.
| # true). Set false to leave asset URLs direct. Bidder <base> removal and | |
| # creative TSJS injection run when this or rewrite_clicks is on. Sanitization | |
| # true). Set false to leave asset URLs direct. When this or rewrite_clicks is | |
| # on, a bidder <base> loses its href (a target is kept), and POST /auction | |
| # creatives get the TSJS runtime. Sanitization |
| parameters. With `rewrite_clicks` off the guard still installs its listeners but never | ||
| acts, because no link carries `data-tsclick`. |
There was a problem hiding this comment.
🔧 wrench: With both switches off, the rewrite pass is skipped (creative.rs:1088), so no TSJS is injected and no guard is installed at all. The case where the guard installs but never acts is assets on, clicks off.
| parameters. With `rewrite_clicks` off the guard still installs its listeners but never | |
| acts, because no link carries `data-tsclick`. | |
| parameters. With `rewrite_clicks` off and `rewrite_creatives` on, the injected | |
| guard installs its listeners but never acts, because no link carries | |
| `data-tsclick`. |
|
|
||
| **Date:** 2026-10-07 | ||
|
|
||
| **Status:** Draft |
There was a problem hiding this comment.
🔧 wrench: #1249 was closed in favor of this PR, so the spec ships here and "Draft" is stale. Related spots in this file:
- :468-469: Rollout steps 1-2 still describe a separate spec PR landing first.
- :493: asks the docs to note that clicks-only TSJS "still lets a creative that turns on
renderGuardproxy script-inserted assets through/first-party/sign". That contradicts decision 3 (:351) and the shippedcreative-processing.md:123-127, which says the opaque-origin sandbox makes the render guard skip signing. - :317 and :364: the citation
click.ts:484-512is one line off at 7a0ecb4; the file has 511 lines, so it should be 483-511.
| **Status:** Draft | |
| **Status:** Implemented in #1252 (spec PR #1249 was closed in favor of it) |
| ("mailto:test@example.com", None), | ||
| ("blob:xyz", None), | ||
| ("tel:+123", None), | ||
| ("about:blank", None), |
There was a problem hiding this comment.
🔧 wrench: Every rejected scheme in this table (data:, javascript:, mailto:, blob:, tel:, about:) has no host, so the host filter alone would still reject it. The http/https prefix check itself is untested.
I checked this with a mutant: replacing the prefix check with "any URL that parses with a host" leaves the whole core suite green. On that mutant, ftp://, ws:// and file:// URLs in creatives get proxied and click-wrapped, and /first-party/sign signs them.
The rows below fail on that mutant and pass on this head. cargo fmt --check and cargo clippy-fastly are clean with them applied.
| ("about:blank", None), | |
| ("about:blank", None), | |
| ("ftp://cdn.example/x", None), | |
| ("ws://cdn.example/x", None), | |
| ("file://cdn.example/x", None), |
| // bidder intended, and remove a `<base>` left without one. The | ||
| // sanitizer also strips <base>, but rewriting must not depend on | ||
| // sanitization, which is independently optional. | ||
| element!("base", |el| { |
There was a problem hiding this comment.
🤔 thinking: On /auction, keeping target only covers part of the creative. The injected click guard calls preventDefault() on any link carrying data-tsclick (click.ts:436). It then navigates using only the anchor's own target:
const target = a.getAttribute('target') || (isMiddle ? '_blank' : '_self');That is click.ts:351; submitRebuildNavigation does the same for form.target. So for a creative with <base target="_blank"> under the default config:
/auction: wrapped landing links still open inside the frame, while raw links (relative, excluded, non-http) now open in a new tab.- Inline SSAT/page-bids, which has no guard: wrapped links now open in a new tab.
The same creative now behaves differently by link type and by delivery path; before this PR every link opened in the frame. Resolving the target the way the browser does would finish the fix:
const baseTarget = document.querySelector('base[target]')?.getAttribute('target');
const target = a.getAttribute('target') || baseTarget || (isMiddle ? '_blank' : '_self');That is a JS change in another crate, so a follow-up issue is fine. If it is deferred, a sentence next to the <base> paragraph in creative-processing.md (:129-132) would set expectations.
| self.exclude_domains = self | ||
| .exclude_domains | ||
| .iter() | ||
| .map(|pattern| pattern.trim().to_ascii_lowercase()) |
There was a problem hiding this comment.
🌱 seedling: Two entry shapes still never match after this normalization. I confirmed both with a scratch test, and both behave the same on main:
- Trailing dot. With
exclude_domains = ["excluded.example.com"],https://excluded.example.com./pis still click-wrapped and proxied, and/first-party/signdoes not reject it as excluded, becausehost_str()keeps the dot. - IDN. An entry written as
bücher.example(or*.bücher.example) never matches.host_str()returnsxn--bcher-kva.example, andto_ascii_lowercaseleaves non-ASCII as is.
This step already drops inert entries with a warning, so it could go further:
- map entries through IDNA (
url::Host::parseoridna::domain_to_ascii) and strip a trailing dot on both sides; or - at least warn for entries that cannot match: non-ASCII, a scheme, port or path, a leading
., or a bare*..
Summary
[auction] rewrite_clickscontrols<a href>and<area href>wrapping into signed/first-party/clickredirects, andrewrite_creativesnow controls asset URLs only. Operators can keep first-party click tracking with assets left direct, or proxy assets with landing links left alone.rewrite_clicksis anOption<bool>, and unset is the default:rewrite_creatives, so no existing config changes behavior on upgrade./first-party/proxykeeps wrapping links as it does today.<base>removal and TSJS injection run when either switch is on.to_absis replaced bynormalize_creative_urlplusRewrite::should_proxy_assetandRewrite::should_wrap_click.exclude_domainsmatching is now case-insensitive and trimmed.Implements the approved design in #1249:
docs/superpowers/specs/2026-10-07-1234-creative-click-rewrite-switch-design.md.Stacked PR. The base is
spec/1234-creative-click-rewrite-switch(#1249), so this diff shows only the implementation. Merge #1249 first; GitHub then retargets this PR tomain. Usegit diff -wwhen readingcreative.rs, because gating the handlers re-indents a large block.Changes
crates/trusted-server-core/src/auction_config_types.rsrewrite_clicks: Option<bool>(left out of the blob when unset),rewrites_auction_clicks(),rewrites_proxied_clicks(), testscrates/trusted-server-core/src/creative.rsnormalize_creative_url,asset_target/click_target,CreativeFeatures. Asset and anchor handlers are registered only when their switch is on. Caller wiring, module docs, the four-combination matrix tests, and a byte-for-byte pin againstmain's outputcrates/trusted-server-core/src/settings.rsRewrite::should_proxy_asset,should_wrap_clickandnormalize(trim, lowercase, drop inert entries); removesis_excluded; testscrates/trusted-server-core/src/proxy.rs/first-party/signuses the normalizer and policy; proxied-HTML click testscrates/trusted-server-core/src/config_payload.rscrates/trusted-server-core/src/auction/formats.rs,publisher.rs/auctionand inline tests; doc and log updatescrates/trusted-server-core/src/auction/orchestrator.rsAuctionConfigliteralcrates/trusted-server-core/src/auction/endpoints.rs,auction/README.mdcrates/trusted-server-cli/tests/config_env_overlay.rsTRUSTED_SERVER__AUCTION__REWRITE_CLICKSoverride behavior, with and without the TOML leaftrusted-server.example.tomlrewrite_creativescomment narrowed to assets; commented-outrewrite_clicksline with rollout notesdocs/guide/configuration.md,creative-processing.md,auction-orchestration.md,api-reference.mdclickGuardvsrewrite_clicks, rollout and rollbackCHANGELOG.mdrewrite_clicks; Fixed case-insensitiveexclude_domainsand the related edge casesCloses
Closes #1234
Test plan
cargo test-fastly && cargo test-axum. Alsocargo test-fastly-reuse,cargo test-cloudflare,cargo test-spin,./scripts/test-cli.shand the parity tests.cargo clippy-fastly && cargo clippy-axum. All 8 clippy aliases pass, and every intermediate commit passesclippy-fastly.cargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest run(no JS changes)cd crates/trusted-server-js/lib && npm run format(no JS changes)cd docs && npm run format, plus the markdown prettier check outsidedocs/cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serveRollout: deploy the binary first; behavior is unchanged while
rewrite_clicksis unset. Then push a config that sets it. Rollback: remove any explicitrewrite_clicksand push first, then roll back the binary.Checklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)