Skip to content

Make creative click rewriting a separate switch - #1252

Open
dhruv8sh wants to merge 17 commits into
mainfrom
feat/1234-creative-click-rewrite-switch
Open

dhruv8sh wants to merge 17 commits into
mainfrom
feat/1234-creative-click-rewrite-switch

Conversation

@dhruv8sh

@dhruv8sh dhruv8sh commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Makes creative click wrapping its own feature. [auction] rewrite_clicks controls <a href> and <area href> wrapping into signed /first-party/click redirects, and rewrite_creatives now controls asset URLs only. Operators can keep first-party click tracking with assets left direct, or proxy assets with landing links left alone.
  • rewrite_clicks is an Option<bool>, and unset is the default:
    • Auction creatives follow rewrite_creatives, so no existing config changes behavior on upgrade.
    • HTML fetched through /first-party/proxy keeps wrapping links as it does today.
    • An explicit value applies to every path.
    • <base> removal and TSJS injection run when either switch is on.
  • Introduces the normalizer and host-policy split that Add a host allowlist for creative asset rewriting #1231 builds on:
    • to_abs is replaced by normalize_creative_url plus Rewrite::should_proxy_asset and Rewrite::should_wrap_click.
    • exclude_domains matching 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 to main. Use git diff -w when reading creative.rs, because gating the handlers re-indents a large block.

Changes

File Change
crates/trusted-server-core/src/auction_config_types.rs rewrite_clicks: Option<bool> (left out of the blob when unset), rewrites_auction_clicks(), rewrites_proxied_clicks(), tests
crates/trusted-server-core/src/creative.rs normalize_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 against main's output
crates/trusted-server-core/src/settings.rs Rewrite::should_proxy_asset, should_wrap_click and normalize (trim, lowercase, drop inert entries); removes is_excluded; tests
crates/trusted-server-core/src/proxy.rs /first-party/sign uses the normalizer and policy; proxied-HTML click tests
crates/trusted-server-core/src/config_payload.rs Blob round-trip and legacy-blob tests
crates/trusted-server-core/src/auction/formats.rs, publisher.rs End-to-end /auction and inline tests; doc and log updates
crates/trusted-server-core/src/auction/orchestrator.rs New field in the full AuctionConfig literal
crates/trusted-server-core/src/auction/endpoints.rs, auction/README.md Doc updates
crates/trusted-server-cli/tests/config_env_overlay.rs Pins the TRUSTED_SERVER__AUCTION__REWRITE_CLICKS override behavior, with and without the TOML leaf
trusted-server.example.toml rewrite_creatives comment narrowed to assets; commented-out rewrite_clicks line with rollout notes
docs/guide/configuration.md, creative-processing.md, auction-orchestration.md, api-reference.md Operator docs: switch matrix, proxied-HTML rule, clickGuard vs rewrite_clicks, rollout and rollback
CHANGELOG.md Added rewrite_clicks; Fixed case-insensitive exclude_domains and the related edge cases

Closes

Closes #1234

Test plan

  • cargo test-fastly && cargo test-axum. Also cargo test-fastly-reuse, cargo test-cloudflare, cargo test-spin, ./scripts/test-cli.sh and the parity tests.
  • cargo clippy-fastly && cargo clippy-axum. All 8 clippy aliases pass, and every intermediate commit passes clippy-fastly.
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run (no JS changes)
  • JS format: cd crates/trusted-server-js/lib && npm run format (no JS changes)
  • Docs format: cd docs && npm run format, plus the markdown prettier check outside docs/
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: every new behavior test was seen failing before it was implemented. A table mapping each spec completion criterion to its test is in the plan.

Rollout: deploy the binary first; behavior is unchanged while rewrite_clicks is unset. Then push a config that sets it. Rollback: remove any explicit rewrite_clicks and push first, then roll back the binary.

Checklist

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

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>
@dhruv8sh dhruv8sh self-assigned this Oct 7, 2026
@dhruv8sh dhruv8sh linked an issue Oct 7, 2026 that may be closed by this pull request
8 tasks
@aram356
aram356 marked this pull request as draft October 8, 2026 15:50
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>
@dhruv8sh

Copy link
Copy Markdown
Collaborator Author

@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 include_domains host round-trip check, the warning for exclude_domains entries that can never match, the <link> let-chain, keeping <base target>, the doc warning on the public rewrite functions, and the operator doc and CHANGELOG corrections. One thing differs from what we discussed: in clicks-only mode <base> still loses its href, because /auction click URLs are root-relative and would otherwise resolve against the bidder's base. The spec explains the tradeoff.

@dhruv8sh
dhruv8sh marked this pull request as ready for review October 12, 2026 13:02
@aram356 aram356 added this to the 202610 milestone Oct 12, 2026
aram356 added a commit that referenced this pull request Oct 12, 2026
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.
aram356 added a commit that referenced this pull request Oct 12, 2026
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.
@dhruv8sh
dhruv8sh changed the base branch from spec/1234-creative-click-rewrite-switch to main October 13, 2026 08:57

@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

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 with cargo fmt --check, cargo clippy-fastly and 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 at CHANGELOG.md:44
  • /first-party/sign error-string claim, missing one-time push diff: see inline at CHANGELOG.md:36
  • <base> removal and TSJS wording: see inline at trusted-server.example.toml:267
  • Click-guard sentence needs the rewrite_creatives qualifier: see inline at docs/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 /auction click guard ignores the kept <base target>: see inline at crates/trusted-server-core/src/creative.rs:1288

🌱 seedling

  • exclude_domains still misses trailing-dot hosts and IDN entries: see inline at crates/trusted-server-core/src/settings.rs:681

Cross-cutting / body-level findings

  • 🔧 Conflicts with main. GitHub reports the branch as conflicting with main (d2f0d8c), and both conflicts are mechanical. In CHANGELOG.md, both sides add ### Fixed bullets, so keep all of them. In Settings::normalize_deserialized (settings.rs), #1221 made the function return Result and call self.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 main into 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_overlay tests (10 passed, both new rewrite_clicks tests included).

    None of the 13 main commits interact with this change semantically:

    • #1239's redirect changes still route proxied HTML through rewrite_proxied_html.
    • #1210's template fingerprint changes once because of the core source edit, which is intended.
    • rewrite_clicks: None is not serialized, so it does not perturb the settings hash.
  • 🔧 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-705 says *.cdn.example.com does not match cdn.example.com ("no subdomain"). is_host_allowed matches the apex (proxy.rs:1246-1250), this PR's rewrite_policy_matches_exclude_patterns_case_insensitively asserts it, and configuration.md:1025 already says ✅. While there, note that matching is case-insensitive.
    • docs/guide/creative-processing.md:968-970 troubleshooting 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: the exclude_domains doc says matching hosts "will not be proxied through first-party endpoints". should_wrap_click uses the same list, so they are not click-wrapped either.
    • crates/trusted-server-core/src/proxy.rs:1621-1624: the # Errors section of handle_first_party_proxy_sign does not list rejection of hosts matched by rewrite.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 the href (keeping target) 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 that sanitize_creatives = true removes <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 to creative-processing.md:129-130.
    • docs/guide/configuration.md:2185: "links still follow rewrite_clicks" reads as if links stay wrapped. With it unset they turn off too. Suggest "links are wrapped only when rewrite_clicks = true".
    • docs/guide/auction-orchestration.md:861-862: "rolling back also changes link wrapping when rewrite_clicks was set" is only true when it differed from rewrite_creatives on the auction paths, or was false, 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

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: 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 … adds data-tsclick … removes bidder-supplied <base> elements". Two things changed:
    • The anchor handler and data-tsclick are registered only when features.clicks (creative.rs:1455-1468), which comes from rewrites_auction_clicks().
    • A <base> with target is now kept (creative.rs:1288-1294).
  • 655-658 table:
    • The false/false row ("exactly as the bidder returned it") and the true/false row ("asset and click URLs remain direct") are false with rewrite_clicks = true.
    • The two rewrite_creatives = true rows ("add click-guard attributes") are false with rewrite_clicks = false.
  • 662-664: "Disabling rewriting removes the injected creative runtime and first-party proxy/click mediation" is false with rewrite_creatives = false and rewrite_clicks = true.
  • 668 and 672: "Both settings apply…" and "HTML/CSS returned by /first-party/proxy continues to be rewritten independently". There are three settings now, and an explicit rewrite_clicks reaches proxied HTML (creative.rs:1140-1152).
  • docs/guide/creative-processing.md:174-177 has 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_creatives and rewrite_clicks removes …"
  • 668: "All three settings apply …"
  • 672: "… rewritten independently of rewrite_creatives; only an explicit rewrite_clicks changes its link wrapping."
  • creative-processing.md:174: "rewrite_creatives and sanitize_creatives do not affect HTML/CSS rewriting under /first-party/proxy; an explicit rewrite_clicks does apply to links there. The publisher SSAT/page-bids path … follows all three settings."

Comment thread CHANGELOG.md
### 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`.

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: 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 its href whenever asset or click rewriting is on (one without target is 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").
Suggested change
- 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`.

Comment thread CHANGELOG.md

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

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: Two precision fixes to this entry.

  1. The /first-party/sign error-string claim. At the merge base, non-http(s) input already answered unsupported url:

    • Non-// input went through to_abs, which returned None for any other scheme.
    • unsupported scheme was reachable only for // input with a non-http(s) request scheme, and normalize_scheme (http_util.rs:224-227) never produces one.

    The only real change is that unparseable http(s) input now gets unsupported url instead of invalid url.

  2. The one-time push diff. Spec rollout step 5 (spec:472) notes that the first ts config push after upgrading shows an exclude_domains diff. Settings serialize normalized, through config.rs:112. Operators reading the CHANGELOG don't see that, unlike the allowed_context_keys entry below, which does warn about its one-time reorder.

Suggested change
- `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.

Comment on lines +266 to +267
# true). Set false to leave asset URLs direct. Bidder <base> removal and
# creative TSJS injection run when this or rewrite_clicks is on. Sanitization

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: "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 passes inject_tsjs = false (creative.rs:1049-1058).
  • A <base> with target now 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 to rewrite_creatives alone.
  • auction/endpoints.rs:93 and auction_config_types.rs:44: "Bidder <base> removal".
  • creative.rs:38-39 (module doc) and creative.rs:1245-1246: TSJS injection "always run[s]", but it depends on inject_tsjs.
Suggested change
# 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

Comment on lines +120 to +121
parameters. With `rewrite_clicks` off the guard still installs its listeners but never
acts, because no link carries `data-tsclick`.

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

Suggested change
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

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: #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 renderGuard proxy script-inserted assets through /first-party/sign". That contradicts decision 3 (:351) and the shipped creative-processing.md:123-127, which says the opaque-origin sandbox makes the render guard skip signing.
  • :317 and :364: the citation click.ts:484-512 is one line off at 7a0ecb4; the file has 511 lines, so it should be 483-511.
Suggested change
**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),

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

Suggested change
("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| {

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.

🤔 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())

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.

🌱 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./p is still click-wrapped and proxied, and /first-party/sign does not reject it as excluded, because host_str() keeps the dot.
  • IDN. An entry written as bücher.example (or *.bücher.example) never matches. host_str() returns xn--bcher-kva.example, and to_ascii_lowercase leaves non-ASCII as is.

This step already drops inert entries with a warning, so it could go further:

  • map entries through IDNA (url::Host::parse or idna::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 *..

This branch has not been deployed

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

Make creative click rewriting a separate feature with its own switch

3 participants