Skip to content

**Spec**: creative asset host allowlist - #1248

Draft
dhruv8sh wants to merge 4 commits into
mainfrom
spec/1231-creative-asset-host-allowlist
Draft

dhruv8sh wants to merge 4 commits into
mainfrom
spec/1231-creative-asset-host-allowlist

Conversation

@dhruv8sh

@dhruv8sh dhruv8sh commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds the design spec and implementation plan for Add a host allowlist for creative asset rewriting #1231. The issue asks for the spec to be approved in its own PR before any implementation PR is opened.
  • The spec adds a [rewrite] include_domains allowlist for creative asset rewriting. Today every absolute asset URL in a winning creative is proxied, so every third-party asset host receives the EC ID. The allowlist lets operators limit that to hosts they choose. Click-through links are not affected.
  • It settles the open decisions in the issue:
    • Setting: [rewrite] include_domains.
    • to_abs split: to_abs becomes a pure normalizer, normalize_creative_url, and host policy moves to Rewrite::should_proxy_asset and Rewrite::should_wrap_click.
    • /first-party/sign: an off-list host gets a 502, the same as an excluded host, so the browser falls back to a direct load.
    • exclude_domains: matching becomes case-insensitive. This is a behavior change and gets a CHANGELOG note.
    • Coordination with Make creative click rewriting a separate feature with its own switch #1234: both plans contain the same shared normalizer and policy step, so whichever implementation lands first introduces it and the other skips it.
  • The spec also fixes an existing bug: on the inline creative path, <link imagesrcset> was proxied twice.

Changes

File Change
docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md Design spec: problem, goals, design, resolved open decisions, test plan, rollout and rollback
docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md Step-by-step TDD implementation plan, including the shared step with #1234

Closes

Part of #1231. This PR adds only the spec and plan. The implementation PR will link the approved spec and close the issue.

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: prettier --config docs/.prettierrc --check on both files. This PR changes no code, so no Rust or JS gates were run. The plan was checked by implementing it in a local branch, where fmt, all clippy aliases, test-fastly, test-fastly-reuse, test-axum, test-cloudflare and test-spin passed. That code is not included here.

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 #1231: a [rewrite] include_domains allowlist for creative asset rewriting, case-insensitive exclude_domains matching, and a single rewrite of link imagesrcset on the inline path.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
@dhruv8sh dhruv8sh self-assigned this Oct 7, 2026
@dhruv8sh dhruv8sh changed the title Add design spec for creative asset host allowlist **Spec**: creative asset host allowlist Oct 7, 2026
Describe the review fixes: strict include_domains validation with a separate non-ASCII error, binary-first upgrade and rollback docs, the distinct link imagesrcset candidate, the host-in-both-lists test across every handler, and the shared-step CHANGELOG entry with the sign status change.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
@dhruv8sh dhruv8sh linked an issue Oct 7, 2026 that may be closed by this pull request
9 tasks

@prk-Jr prk-Jr 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

Spec and plan for #1231's [rewrite] include_domains asset-host allowlist. The design holds up: the code claims check out against 7a0ecb4c, the <link imagesrcset> double rewrite is real (reproduced against lol_html 2.9.0), the shared step is byte-identical with #1249's, and the plan's snippets compile and pass clippy. One blocking issue: the include-list validator doesn't meet its own stated guarantee for some IPv6 and xn-- entries.

7 of the inline comments below carry a one-click GitHub suggestion — use Commit suggestion (or Add suggestion to batch) to apply them. The remaining comments describe the fix in prose because it spans several places or lines outside a single range.

Blocking

🔧 wrench

  • Validator's IPv6 check uses Rust's formatter, not host_str()'s — see inline at docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md:967

Non-blocking

⛏ nitpick

  • Step 5 grep can never pass — see inline at docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md:671
  • Shared step: is_host_permitted visibility contradicts the plan — see inline at docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md:312
  • Only test-fastly runs core tests — see inline at docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md:321
  • rewrite_clicks doesn't exist on main — see inline at docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md:1720
  • Leftover sentence in Task 3 Step 2 — see inline at docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md:876
  • Module-doc anchor that doesn't exist — see inline at docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md:1479
  • Output-parity and blob-hash wording — see inline at docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md:233
  • GTM citation points at test code — see inline at docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md:94
  • Test-plan cases the plan doesn't include — see inline at docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md:325

♻️ refactor

  • link[href] snippet relies on comment placement to pass clippy — see inline at docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md:1593-1613
  • Task 2 red step can't be observed — see inline at docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md:657

🤔 thinking

  • Inert exclude_domains entries stay silent — see inline at docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md:132

Cross-cutting / body-level findings

  • 📌 Trailing-dot hosts bypass exclude_domains (pre-existing) — https://blocked.example.com./x has host_str() blocked.example.com., which matches neither blocked.example.com nor *.example.com in is_host_allowed (proxy.rs:1242-1255), so it is proxied, signed and gets ts-ec despite the exclusion. include_domains fails safe here (an off-list host stays raw). Not for this PR; worth a follow-up issue (e.g. strip one trailing . before matching).
  • 👍 Solid design choices — the sign handler's ordering keeps 403 reserved for proxy.allowed_domains; the normalizer/policy split is clean and parse-once; the shared step with #1234 is reproducible verbatim; and the plan's red/green expectations line up with the code paths.

CI Status

  • format-docs: PASS
  • format-typescript: PASS
  • CLAUDE.md symlink guard: PASS
  • CodeQL: SKIPPED
  • cargo fmt, cargo test, cargo test (axum native), cargo test (cross-adapter parity), cargo check/build/test (spin native + wasm32-wasip1), cargo check (cloudflare native + wasm32-unknown-unknown), cargo test (ts CLI, native) (ubuntu-latest / macos-latest), vitest, prepare integration artifacts, Analyze (rust / javascript-typescript / actions / python): PENDING at review time (docs-only change)

.strip_suffix(']')
.and_then(|inner| inner.parse::<std::net::Ipv6Addr>().ok())
.ok_or("not a bracketed IPv6 address")?;
if format!("[{address}]") != pattern {

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 — The IPv6 check compares against Rust's Ipv6Addr Display, not what Url::host_str() renders, so validate_include_domains does not do what spec :133 promises ("rejects every entry that can never equal Url::host_str()"). I ran this function verbatim against url =2.5.8 / validator =0.20.0:

Entry Validator host_str()
[::ffff:192.0.2.1] accepts renders [::ffff:c000:201] — never matches
[::ffff:c000:201] (the real form) rejects ("not in compressed form") —
xn--zz.example accepts Url::parse fails (IDNA error) — never matches

Proposed fix (apply manually — touches the plan's validator and the test lists in both the plan and spec :330-332). Keep the existing syntactic checks for their error reasons, then finish with a canonical round trip through the same parser the matcher depends on:

let host = url::Host::parse(name).map_err(|_| "not a valid URL host")?;
if host.to_string() != name {
    return Err("not in the form Url::host_str() renders");
}

url::Host's Display brackets IPv6, so apply it to the full pattern for […] entries. Add [::ffff:c000:201] (accept), [::ffff:192.0.2.1] and xn--zz.example (reject) to the test lists.


Delete `Rewrite::is_excluded` (with its `#[allow(dead_code)]`) and the `test_rewrite_is_excluded` test (`settings.rs:6522-6546`); Task 1's tests cover its cases.

Run: `git grep -n "to_abs\|is_excluded(" crates/`

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.

⛏ nitpick — This grep can never come back empty: it also matches auction/telemetry.rs:1036 (…_default_to_absent_…) and publisher.rs:22620 (…_to_absolute_first_party_urls). The word-bounded form only hits the intended files.

Suggested change
Run: `git grep -n "to_abs\|is_excluded(" crates/`
Run: `git grep -nw -e to_abs -e is_excluded crates/`

Both specs describe the same split, so either implementation can land first:

- `to_abs` is replaced by `normalize_creative_url(url: &str) -> Option<url::Url>` in `creative.rs`: trimming, protocol-relative, `http(s)` only, no policy.
- Policy lives on `Rewrite` in `settings.rs` as `should_proxy_asset(&self, host: &str) -> bool` (not excluded and (include list empty or host included)) and `should_wrap_click(&self, host: &str) -> bool` (not excluded), with a private `is_excluded_host`. Both use the existing case-insensitive matchers `proxy::is_host_allowed` and `proxy::is_host_permitted` (made `pub(crate)`): an exact host, or `*.example.com` matching the apex and subdomains.

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.

⛏ nitpick — This bullet says the shared split uses is_host_permitted "made pub(crate)" and gives the shared should_proxy_asset the include clause. The plan's Shared step (:49), #1249, and line 317 here all say the shared step is exclude-only and is_host_permitted stays private (importing it would trip -D warnings as unused).

Suggested change
- Policy lives on `Rewrite` in `settings.rs` as `should_proxy_asset(&self, host: &str) -> bool` (not excluded and (include list empty or host included)) and `should_wrap_click(&self, host: &str) -> bool` (not excluded), with a private `is_excluded_host`. Both use the existing case-insensitive matchers `proxy::is_host_allowed` and `proxy::is_host_permitted` (made `pub(crate)`): an exact host, or `*.example.com` matching the apex and subdomains.
- Policy lives on `Rewrite` in `settings.rs` as `should_proxy_asset(&self, host: &str) -> bool` (not excluded) and `should_wrap_click(&self, host: &str) -> bool` (not excluded), with a private `is_excluded_host`. Both use the existing case-insensitive matcher `proxy::is_host_allowed`: an exact host, or `*.example.com` matching the apex and subdomains. `proxy::is_host_permitted` stays private in the shared step; #1231 makes it `pub(crate)` when it adds the include clause.


## Test plan

All tests are unit tests in `trusted-server-core` and run under `cargo test-fastly`, `cargo test-axum`, `cargo test-cloudflare` and `cargo test-spin`. Hosts are under `example.com`, `example.net` and `example.org`.

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.

⛏ nitpick — Only test-fastly includes -p trusted-server-core; test-axum, test-cloudflare and test-spin are scoped to their adapter crates (.cargo/config.toml:42-60), so they don't run these tests. Native core runs are also much faster than Viceroy and sidestep the "a wasm panic aborts the binary" workaround the plan repeats.

Suggested change
All tests are unit tests in `trusted-server-core` and run under `cargo test-fastly`, `cargo test-axum`, `cargo test-cloudflare` and `cargo test-spin`. Hosts are under `example.com`, `example.net` and `example.org`.
All tests are unit tests in `trusted-server-core` and run under `cargo test-fastly` (or natively with `cargo test -p trusted-server-core --target <host-triple>`). Hosts are under `example.com`, `example.net` and `example.org`.

:::
```

The warning box mirrors the `[auction]` one for `rewrite_clicks`.

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.

⛏ nitpick — rewrite_clicks doesn't exist on main; it's #1234's [auction] rewrite_clicks. If this lands first the reference dangles. The existing analogue is the rewrite_creatives upgrade/rollback box (configuration.md:1959). Same applies to spec :379 ("mirroring the rewrite_clicks warning") and spec :388 — worth qualifying those as [auction] rewrite_clicks / conditional on #1234 too.

Suggested change
The warning box mirrors the `[auction]` one for `rewrite_clicks`.
The warning box mirrors the existing `[auction] rewrite_creatives` upgrade and rollback box (and #1234's `[auction] rewrite_clicks` box if that has landed).


Run: `cargo test-fastly -- normalize_creative_url proxy_if_abs_respects unparseable_absolute exclude_domains_match_case proxy_sign_rejects_excluded`

Expected: `error[E0432]: unresolved import super::normalize_creative_url`. `unparseable_absolute_click_url_is_left_byte_identical`, `exclude_domains_match_case_insensitively_in_the_rewrite_pass` and `proxy_sign_rejects_excluded_urls_case_insensitively` use only existing APIs. Against the pre-change code they fail at runtime: the anchor gains `data-tsclick`, the mixed-case-excluded link is wrapped, and sign returns `200`. Run each alone with `--exact <name> --nocapture` to see the panic.

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.

♻️ refactor — These red-step failures can't be observed: Step 1 switches the test use super::{…} to normalize_creative_url, so the whole core test binary fails to compile before Step 3. The predicted runtime failures are right by analysis, but no executor will see them. Suggest: add unparseable_absolute_click_url_is_left_byte_identical, exclude_domains_match_case_insensitively_in_the_rewrite_pass and proxy_sign_rejects_excluded_urls_case_insensitively first with the old import, watch them fail, then switch the import.

Add `Rewrite::normalize(&mut self)` and call it from `Settings::normalize_deserialized` (`settings.rs:2988`), next to `self.proxy.normalize()`. That function runs on every load path: TOML, config blob, and `TrustedServerAppConfig` deserialization used by `ts config push` validation (`config.rs:111-112`).

- Both lists: trim each entry and lowercase it with `to_ascii_lowercase`.
- `exclude_domains`: drop empty entries and a bare `*` with a `log::warn!`, as `Proxy::normalize` does. Neither can match a host today, so dropping them changes no behavior, and rejecting them would make an existing blob fail to load after a binary upgrade.

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 — exclude_domains gets no validation, so an inert entry like https://cdn.example.com, cdn.example.com:443 or [2001:0db8::1] silently never matches — that host keeps being proxied and receives ts-ec, the leak #1231 is about. Rejecting would break existing blobs (agreed), but Rewrite::normalize could run the same check_include_domain and log::warn! once per inert entry, with zero behavior change.

Related: issue #1231 says to reject empty entries and a bare *; dropping with a warning is the better call, but it's worth flagging explicitly as a deviation from the issue (as #1249 does for its own).


Because no caller reads the original string, every handler in the table gets the allowlist with no per-handler code. Nested HTML and CSS fetched through `/first-party/proxy` run the same pass and pick it up too.

**Output parity with an empty allowlist.** `build_signed_url_for` already parses the clear URL and signs its `Url` serialization, and `Url::parse(url.as_str())` round-trips, so for every input that parses, the rewritten bytes are identical to today's. The only difference is an absolute-looking value that `Url::parse` rejects (for example `https://exa mple.example/x`). Today `to_abs` accepts it and `build_signed_url_for` hands back the raw string, so the handler rewrites the attribute to its own value and `CssUrlRewriter` re-serializes it as a quoted string. After the change the value is left untouched. No existing fixture contains such a URL; `unparseable_absolute_url_is_left_byte_identical` pins the new behavior.

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.

⛏ nitpick — Two precision points:

  • Today's parse-failure fallback emits the trimmed, https:-prefixed string, not "its own value" — e.g. src=" //exa mple.example/x " becomes https://exa mple.example/x. Anchors also gain data-tsclick with it (the CHANGELOG text says this; this paragraph doesn't).
  • Line 123's "existing blob hashes do not change" only holds for configs that are already normalized: TrustedServerAppConfig serializes the normalized settings (config.rs:97-104), so after this change ts config push writes lowercased/trimmed exclude_domains for mixed-case or whitespace entries. proxy.allowed_domains already behaves this way, so it's fine — just qualify the sentence.


`Rewrite::is_excluded` (`settings.rs:647-669`) parses the URL, takes `host_str()` (already lowercase), and compares it against raw entries with `==` and `ends_with`. An entry such as `CDN.example.com` never matches. It still carries a stale `#[allow(dead_code)]` (`settings.rs:647`).

`proxy::is_host_allowed` (`proxy.rs:1242-1255`) lowercases both sides and enforces a dot boundary: `example.com` matches only itself; `*.example.com` matches `example.com` and any subdomain, not `evil-example.com`. Prebid (`integrations/prebid.rs:918`) and GTM (`integrations/google_tag_manager.rs:1983`) reuse it. `Proxy::normalize` (`settings.rs:1766-1789`) trims and lowercases `allowed_domains`, drops empty entries, and drops a bare `*` with a warning.

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.

⛏ nitpick — google_tag_manager.rs:1983 is inside the #[cfg(test)] module. Production GTM reaches the matcher indirectly via .with_allowed_domains(...) (:805) → redirect policy → is_host_permitted. The claim is true in substance; the citation points at test code.


### `settings.rs`

- `should_proxy_asset` table: empty include list proxies any host; exact match; wildcard matches apex and nested subdomain; wildcard does not match `evil-example.com`; off-list host is rejected; a host in both lists is rejected; uppercase host and uppercase entry both match.

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.

⛏ nitpick — A few test-plan promises the plan doesn't deliver:

  • :325 — an uppercase include entry in should_proxy_asset; the plan's policy test only pushes lowercase entries, so uppercase is covered only via TOML normalization. Push e.g. "*.EXAMPLE.com" directly.
  • :333 — the port of test_rewrite_is_excluded drops its suffix-spoof case (example2.com.fake.com); add something like ("example.org.evil.example.net", true) to the Task 1 table.
  • :339 — lists video/audio/source src, but the matrix creative has only <video src>.

@aram356
aram356 marked this pull request as draft October 9, 2026 05:27
@aram356

aram356 commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

@dhruv8sh Please merge into implementation PR #1253

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.

Add a host allowlist for creative asset rewriting

3 participants