Repository navigation
Conversation
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>
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>
Add the [rewrite] include_domains field with load-time normalization, strict entry validation and blob round-trip tests. Enforcement arrives in the next commit, which applies the list to asset rewriting and first-party sign. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Apply the include list to every asset handler and to first-party sign. Click-through links and fetch-time proxy checks are unchanged. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
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>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Round-trip include_domains entries through url::Host::parse so IPv4-mapped IPv6 and invalid xn-- entries are rejected, warn on exclude_domains entries that never match, and fix the grep, red-step, citation and wording issues raised in review. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
…-creative-asset-host-allowlist
Finish include_domains validation by parsing each entry with url::Host and requiring its rendered form, so dotted IPv4-mapped IPv6 and invalid xn-- entries are rejected. Warn at load on exclude_domains entries that can never match a host. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Collapse the link[href] condition into a let-chain so clippy::collapsible_if does not depend on comment placement, and move the include_domains module-doc bullet after the scheme bullet. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Describe the IPv6 form URL hosts render, the rejection of entries a URL parser would rewrite, and the load-time warning for exclude_domains entries that never match. 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 #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
This PR adds [rewrite] include_domains and applies it to every asset handler on all three HTML paths, to CSS bodies, and to /first-party/sign. It also fixes the inline <link imagesrcset> double proxy. The core change holds up:
- A mutation pass over the new code (12 mutants, run on the PR merged with
main) caught 10. - The merged tree passes
clippy-fastlyandtest-fastly. - Every proxy URL builder goes through
asset_target, so nothing bypasses the allowlist.
Three things block merging:
- the conflict with
main; - the #1252 commits this branch carries;
- an
exclude_domainswarning that tells operators to delete entries that work.
4 of the inline comments below carry a one-click GitHub
suggestion. Use Commit suggestion, or Add suggestion to batch to apply several at once, to add them as commits on the PR branch. The other comments describe the fix in prose, because it spans several hunks or files.
Blocking
🔧 wrench
- The
exclude_domainswarning flags entries that do match, including the trailing-dot workaround. See inline atcrates/trusted-server-core/src/settings.rs:708.
Non-blocking
🤔 thinking
- Sign declines log at
errorand count as 5xx on every adapter. See inline atcrates/trusted-server-core/src/proxy.rs:1692. - The include grammar rejects real hosts (
_), and the0xclause is redundant. See inline atcrates/trusted-server-core/src/settings.rs:3494.
♻️ refactor
- The validation error doesn't name the bad entry. See inline at
crates/trusted-server-core/src/settings.rs:3450.
⛏ nitpick
- Unset
rewrite_clicksfollowsrewrite_creativeson the auction paths. See inline atdocs/guide/creative-processing.md:762. - The spec status is stale. See inline at
docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md:7.
Cross-cutting / body-level findings
-
🔧 Conflicts with
main. Mergingorigin/main(d2f0d8c) conflicts in two files:CHANGELOG.md: both sides addFixedentries. Keep both.settings.rsnormalize_deserialized: #1221 madeimage_optimizer.normalize()returnResult. The resolution is:
self.rewrite.normalize(); self.image_optimizer.normalize()?;
With that resolution, the merged tree passes
cargo fmt --check,cargo clippy-fastlyandcargo test-fastly(2964 core tests). -
🔧 This branch carries #1252, including #1252's open blocking threads. The base is now
main, so this diff includes therewrite_clickswork in the same state as #1252's7c5eca0da. Review 5507201594 on #1252 still has six open 🔧 threads:auction-orchestration.md;- two CHANGELOG items;
- the example TOML;
- the
creative-processing.mdguard sentence; - the #1234 spec status.
Merging this PR first would land all of them. As the description plans, land #1252 first, then rebase so only the #1231 commits remain. Two related problems:
- The description is out of date. It names
spec/1231-…as the base and lists four commits under review. The base ismain, and the #1231 work now also includesf7885c199,e1332c9fe,2af735d1dand the spec feedback incce4e2a5e. - The shared
Fixedentry in CHANGELOG.md no longer matches #1252's copy. This one adds "and other entries that can never match a host are logged with a warning". The plan says the shared text is identical in both PRs, so whichever lands second will conflict on that line.
-
📌 The trailing-dot bypass needs its follow-up issue. The spec's Follow-ups section says to track it in a follow-up issue, and I found none.
https://blocked.example.com./xhashost_str()blocked.example.com., so it escapesexclude_domainsand still getsts-ec. The spec suggests stripping one trailing.before matching. Until then, an exclude entry written with the trailing dot is the stopgap, which is why the wrench above matters.
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
| // An `exclude_domains` entry that can never equal a URL host keeps its | ||
| // host proxied and receiving `ts-ec`. Rejecting it would stop existing | ||
| // blobs from loading, so warn once per entry and keep it. | ||
| for pattern in &self.exclude_domains { | ||
| if let Err(reason) = check_include_domain(pattern) { | ||
| log::warn!( | ||
| "rewrite.exclude_domains: entry `{pattern}` never matches a URL host ({reason})" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| false | ||
| } |
There was a problem hiding this comment.
🔧 wrench: This warning says an entry "never matches a URL host". But check_include_domain implements the stricter include_domains grammar, so it also flags entries that do match. With the locked url 2.5.8:
exclude_domains entry |
URL it matches |
|---|---|
blocked.example.com. |
https://blocked.example.com./x (host_str() keeps the trailing dot) |
ads_cdn.example.com |
https://ads_cdn.example.com/x |
*.192.0.2.1 |
https://192.0.2.1/x (apex rule in is_host_allowed) |
The first row matters most. The spec's Follow-ups section says trailing-dot hosts bypass exclude_domains, and an entry written with the trailing dot is the only way to close that bypass today. This warning, plus configuration.md ("fix them, or their hosts stay proxied"), tells the operator to delete that entry, and deleting it turns ts-ec forwarding back on for the host.
I reproduced this with the PR-head ts binary. ts config validate --no-env on exclude_domains = ["ads_cdn.example.com", "https://cdn.example.com"] exits 0 and prints:
rewrite.exclude_domains: entry `ads_cdn.example.com` never matches a URL host (character outside a-z, 0-9, `-` and `.`)
rewrite.exclude_domains: entry `https://cdn.example.com` never matches a URL host (character outside a-z, 0-9, `-` and `.`)
The suggestion gives exclude_domains its own check. The URL parser decides (check_host_round_trip on the entry minus *.). Two shapes that no fetchable host has are still flagged: a * other than a leading *., and a leading . or an empty label. All three entries in rewrite_exclude_domains_keep_entries_that_never_match still warn.
The warning also has no test. Deleting the whole loop leaves every include/rewrite/imagesrcset/proxy_sign test passing (I ran a mutation pass on the PR merged with main). Once the check is a function, it can be tested. This test sits outside the hunk, so add it by hand next to rewrite_exclude_domains_keep_entries_that_never_match. It passes with the suggestion applied:
#[test]
fn rewrite_exclude_domains_flag_only_entries_that_never_match() {
for entry in [
"https://cdn.example.com",
"cdn.example.com:443",
"[2001:0db8::1]",
"cdn.*.example.com",
".example.com",
"bücher.example",
] {
assert!(
Rewrite::check_exclude_domain(entry).is_err(),
"should flag `{entry}`, which never matches a URL host"
);
}
for entry in [
"blocked.example.com.",
"ads_cdn.example.com",
"*.192.0.2.1",
"*.example.com",
"[::1]",
] {
assert!(
Rewrite::check_exclude_domain(entry).is_ok(),
"should not flag `{entry}`, which matches a URL host"
);
}
}| // An `exclude_domains` entry that can never equal a URL host keeps its | |
| // host proxied and receiving `ts-ec`. Rejecting it would stop existing | |
| // blobs from loading, so warn once per entry and keep it. | |
| for pattern in &self.exclude_domains { | |
| if let Err(reason) = check_include_domain(pattern) { | |
| log::warn!( | |
| "rewrite.exclude_domains: entry `{pattern}` never matches a URL host ({reason})" | |
| ); | |
| } | |
| } | |
| false | |
| } | |
| // An `exclude_domains` entry that can never equal a URL host keeps its | |
| // host proxied and receiving `ts-ec`. Rejecting it would stop existing | |
| // blobs from loading, so warn once per entry and keep it. | |
| for pattern in &self.exclude_domains { | |
| if let Err(reason) = Self::check_exclude_domain(pattern) { | |
| log::warn!( | |
| "rewrite.exclude_domains: entry `{pattern}` never matches a URL host ({reason})" | |
| ); | |
| } | |
| } | |
| } | |
| /// Checks one normalized `exclude_domains` entry, returning why no fetched | |
| /// URL can match it. | |
| /// | |
| /// Looser than the `include_domains` grammar on purpose: a trailing `.`, a | |
| /// `_` and a wildcard IP address all still match a URL host, and a | |
| /// trailing-dot entry is the only way to exclude `blocked.example.com.`. | |
| fn check_exclude_domain(pattern: &str) -> Result<(), &'static str> { | |
| let host = pattern.strip_prefix("*.").unwrap_or(pattern); | |
| let labels = host.strip_suffix('.').unwrap_or(host); | |
| if host.contains('*') { | |
| Err("`*` other than a leading `*.`") | |
| } else if labels.split('.').any(str::is_empty) { | |
| Err("leading `.` or empty label") | |
| } else { | |
| check_host_round_trip(host) | |
| } | |
| } |
| if !pattern.is_ascii() { | ||
| let mut err = ValidationError::new("non_ascii_rewrite_include_domain"); | ||
| err.add_param("value".into(), pattern); | ||
| err.message = Some( | ||
| "rewrite.include_domains entries must be ASCII; write internationalized domain names in punycode (xn--) form" | ||
| .into(), | ||
| ); | ||
| return Err(err); | ||
| } | ||
| if let Err(reason) = check_include_domain(pattern) { | ||
| let mut err = ValidationError::new("invalid_rewrite_include_domain"); | ||
| err.add_param("value".into(), pattern); | ||
| err.add_param("reason".into(), &reason); | ||
| err.message = Some( | ||
| "rewrite.include_domains entries must be a host name, an optional leading `*.`, a dotted-quad IPv4 address or a bracketed IPv6 address" | ||
| .into(), | ||
| ); | ||
| return Err(err); | ||
| } |
There was a problem hiding this comment.
♻️ refactor: When a list contains a bad entry, the error doesn't say which entry it is. With the PR-head ts binary, include_domains = ["assets.example.com", "img.example.com", "cdn.example.com:443", "*.static.example.net"] fails ts config validate --no-env with only:
[ts] typed app-config failed validation: rewrite.include_domains: rewrite.include_domains entries must be a host name, an optional leading `*.`, a dotted-quad IPv4 address or a bracketed IPv6 address
The CLI prints message and drops the value and reason params. The edge load path shows even less: validation_error_summary prints only the code (rewrite.include_domains: invalid_rewrite_include_domain). Putting the entry and the reason into the message fixes what operators see at ts config validate and push. The params stay for structured consumers, and the codes the tests match on don't change.
With the suggestion applied, the same config prints:
[ts] typed app-config failed validation: rewrite.include_domains: rewrite.include_domains entry `cdn.example.com:443` must be a host name with an optional leading `*.`, a dotted-quad IPv4 address or a bracketed IPv6 address (character outside a-z, 0-9, `-` and `.`)
| if !pattern.is_ascii() { | |
| let mut err = ValidationError::new("non_ascii_rewrite_include_domain"); | |
| err.add_param("value".into(), pattern); | |
| err.message = Some( | |
| "rewrite.include_domains entries must be ASCII; write internationalized domain names in punycode (xn--) form" | |
| .into(), | |
| ); | |
| return Err(err); | |
| } | |
| if let Err(reason) = check_include_domain(pattern) { | |
| let mut err = ValidationError::new("invalid_rewrite_include_domain"); | |
| err.add_param("value".into(), pattern); | |
| err.add_param("reason".into(), &reason); | |
| err.message = Some( | |
| "rewrite.include_domains entries must be a host name, an optional leading `*.`, a dotted-quad IPv4 address or a bracketed IPv6 address" | |
| .into(), | |
| ); | |
| return Err(err); | |
| } | |
| if !pattern.is_ascii() { | |
| let mut err = ValidationError::new("non_ascii_rewrite_include_domain"); | |
| err.add_param("value".into(), pattern); | |
| err.message = Some( | |
| format!( | |
| "rewrite.include_domains entry `{pattern}` is not ASCII; write internationalized domain names in punycode (xn--) form" | |
| ) | |
| .into(), | |
| ); | |
| return Err(err); | |
| } | |
| if let Err(reason) = check_include_domain(pattern) { | |
| let mut err = ValidationError::new("invalid_rewrite_include_domain"); | |
| err.add_param("value".into(), pattern); | |
| err.add_param("reason".into(), &reason); | |
| err.message = Some( | |
| format!( | |
| "rewrite.include_domains entry `{pattern}` must be a host name with an optional leading `*.`, a dotted-quad IPv4 address or a bracketed IPv6 address ({reason})" | |
| ) | |
| .into(), | |
| ); | |
| return Err(err); | |
| } |
| if !name.bytes().all(|byte| { | ||
| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.') | ||
| }) { | ||
| return Err("character outside a-z, 0-9, `-` and `.`"); |
There was a problem hiding this comment.
🤔 thinking: The doc comment says this rejects entries that "can never equal a URL host", but the character class also rejects hosts that URLs do carry:
_:Url::parse("https://cdn_assets.example.com/")giveshost_str() == "cdn_assets.example.com", andurl::Host::parseround-trips it. The PR-headts config validaterejectsinclude_domains = ["cdn_assets.example.com"]with the generic message. An operator whose asset host has an underscore can only allowlist it through a wider wildcard such as*.example.com.0x(line 3504):check_host_round_tripalready rejects every0xlabel thaturlreads as a number.foo.0xabcfails to parse, and0xc0000201renders as192.0.2.1. The clause only adds false rejections such asfoo.0xcdn, whichhost_str()returns unchanged. Deleting it leaves the suite passing (mutation run).
If strict letter-digit-hyphen names are the intent, say so in the doc comment and configuration.md, and leave the code. Otherwise:
if !name.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.' | b'_')
}) {
return Err("character outside a-z, 0-9, `-`, `_` and `.`");
} let numeric_last_label = last_label.bytes().all(|byte| byte.is_ascii_digit());Then move cdn_assets.example.com from rewrite_include_domains_reject_malformed_entries to rewrite_include_domains_accept_hosts_and_ip_literals, and update the grammar bullet in configuration.md and the spec. This has to be applied by hand: the test lists are in another hunk, and the code change alone fails the reject test.
| if !settings.rewrite.should_proxy_asset(host) { | ||
| log::debug!( | ||
| "sign request for `{host}` declined: host excluded or not in rewrite.include_domains" | ||
| ); | ||
| return Err(Report::new(TrustedServerError::Proxy { | ||
| message: "unsupported url".to_string(), | ||
| })); | ||
| } |
There was a problem hiding this comment.
🤔 thinking: The spec (decision 3) logs declines at debug because "declines are expected in normal operation once an allowlist is set". The Err returned here undoes that. Every adapter's http_error logs Error occurred: … at error level before it builds the 502: fastly app.rs:999, axum app.rs:120, cloudflare app.rs:329 and spin app.rs:472.
Once include_domains is set, most ad-tech hosts are off-list. Each image or iframe that a creative inserts at runtime then costs a sign round-trip that comes back 502. That adds one error log line and one 5xx in edge metrics, and the client logs tsjs-creative: sign HTTP error 502 (proxy_sign.ts:62). That noise can bury real proxy failures and trip 5xx alerts. exclude_domains already behaved this way, but exclusions are rare; with an allowlist, a decline becomes the common case.
Options, smallest first:
- Return the decline as
Okwith a 502 response built here. That skipshttp_error's log line and keeps the status and the JS contract.proxy_sign_rejects_excluded_urlsandproxy_sign_rejects_excluded_urls_case_insensitivelycallexpect_err, so they would read the status from the response instead. - Use a 4xx status, such as
422, for policy declines.proxy_sign.tsalready falls back to a direct load on any status other than 403, so 5xx stays reserved for real upstream failures. This changes the documented502in api-reference.md and first-party-proxy.md.
Separately, the # Errors section on this function (lines 1621-1624, outside the diff) still lists only parse, scheme, host and proxy.allowed_domains failures. It doesn't mention the exclude_domains / include_domains decline.
| - Click-through links (`<a href>`, `<area href>`) are not affected. Whether | ||
| they are wrapped in `/first-party/click` depends only on `rewrite_clicks` and | ||
| `exclude_domains`. |
There was a problem hiding this comment.
⛏ nitpick: "depends only on rewrite_clicks" reads as if an unset rewrite_clicks were its own state. On /auction and SSAT/page-bids, an unset rewrite_clicks follows rewrite_creatives (rewrites_auction_clicks). So with rewrite_creatives = false and rewrite_clicks unset, links are not wrapped.
| - Click-through links (`<a href>`, `<area href>`) are not affected. Whether | |
| they are wrapped in `/first-party/click` depends only on `rewrite_clicks` and | |
| `exclude_domains`. | |
| - Click-through links (`<a href>`, `<area href>`) are not affected. Whether | |
| they are wrapped in `/first-party/click` depends only on `rewrite_clicks` | |
| (which follows `rewrite_creatives` on the auction paths when unset) and | |
| `exclude_domains`. |
|
|
||
| **Date:** 2026-10-07 | ||
|
|
||
| **Status:** Draft (design validated by an exploratory implementation; see the plan, `docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md`) |
There was a problem hiding this comment.
⛏ nitpick: #1248 was closed in favor of this PR, so the spec ships here and "Draft" will be stale once this merges. This is the same item as the #1234 spec thread on #1252. #1248 closed with prk-Jr's CHANGES_REQUESTED still standing, so the approval of this PR is also the spec's approval.
One related line: the Rollout section (line 377) says that with no include_domains, "asset behavior is unchanged except for the exclude_domains case fix". Deploying the binary alone also changes three things:
- inline
<link imagesrcset>output (the double-proxy fix); - unparseable absolute URLs are now left untouched;
- the new
exclude_domainswarnings appear.
| **Status:** Draft (design validated by an exploratory implementation; see the plan, `docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md`) | |
| **Status:** Implemented in #1253 (see the plan, `docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md`) |
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Please address the host-classification issue: valid DNS names such as 0xproxy are rejected as IPv4 candidates, preventing otherwise valid allowlist configurations.
Also clarify the changelog and operator guidance: include_domains controls newly rewritten or signed asset targets, not fetch-time enforcement. Previously issued proxy URLs can still forward EC IDs to hosts subsequently removed from the list. This is a documentation correction, not a request to expand runtime policy.
| let last_label = name.rsplit('.').next().unwrap_or(name); | ||
| let numeric_last_label = | ||
| last_label.bytes().all(|byte| byte.is_ascii_digit()) || last_label.starts_with("0x"); |
There was a problem hiding this comment.
[P3] Do not classify every 0x-prefixed hostname as IPv4
Valid DNS hosts such as 0xproxy and a.0xproxy are rejected because their final label starts with 0x, even though the remainder is not hexadecimal and the URL parser treats them as domain names. Consequently, include_domains = ["0xproxy"] or ["*.0xproxy"] fails configuration validation instead of allowing those hosts. Use the URL parser's actual host classification; the existing round-trip check can still reject malformed or noncanonical hosts.
| let last_label = name.rsplit('.').next().unwrap_or(name); | |
| let numeric_last_label = | |
| last_label.bytes().all(|byte| byte.is_ascii_digit()) || last_label.starts_with("0x"); | |
| let numeric_last_label = matches!(url::Host::parse(name), Ok(url::Host::Ipv4(_))); |
| ### 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. | ||
| - `[rewrite] include_domains` limits creative asset rewriting to listed hosts. When non-empty, only asset URLs on matching hosts are proxied through `/first-party/proxy`, so only those hosts receive the EC ID on proxied fetches. Other assets keep their original URL, and `/first-party/sign` returns `502` for them so the creative runtime loads them directly. Click-through links are not affected, and `exclude_domains` still wins. Entries must be host names, optionally prefixed with `*.`, dotted-quad IPv4 addresses, or bracketed IPv6 addresses; entries with a scheme, port, path, spaces or non-ASCII characters are rejected at load. Upgrading: deploy the binary first, then push a config that sets `include_domains`. Rolling back: remove `include_domains`, push the resulting config, then roll back the binary; older binaries reject the key because `[rewrite]` denies unknown fields. |
There was a problem hiding this comment.
[P3] Qualify the claim that only included hosts receive EC IDs
Non-blocking documentation/contract correction; correctness and architecture lanes. The claim that “only those hosts receive the EC ID on proxied fetches” is broader than the implementation. For example, obtain a creative-generated proxy URL for cdn.example.net, then configure an include list that excludes it without changing the publisher secret. Static creative URLs have no tsexp (creative.rs:600–643), signature validation does not consult rewrite policy (proxy.rs:2071–2152), and /first-party/proxy still forwards the EC ID subject only to proxy.allowed_domains (proxy.rs:1506–1528). Previously delivered creatives can therefore continue forwarding IDs to now-off-list hosts. Qualify the changelog and operator guidance: the list controls newly rewritten/signed asset targets, not previously issued URLs or fetch-time enforcement. This preserves the spec’s explicit non-goals; it does not propose expanding runtime policy.
Summary
[rewrite] include_domains, a host allowlist for creative asset rewriting./first-party/proxy, and each proxied fetch appends the EC ID, so every third-party asset host receives it.exclude_domainsstill wins over the list, and click-through links are never affected./first-party/signapplies the same policy for absolute and//input.502, the same as an excluded host, so the creative runtime loads the raw URL directly.403: still means only "blocked byproxy.allowed_domains".ts config push. That includes schemes, ports, paths, stray dots, unbracketed IPv6, and non-ASCII names, which must be written in punycode.<link imagesrcset>was proxied twice, wrapping the proxy URL inside another proxy URL.Implements the approved design in #1248:
docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md.Stacked on #1252. This branch builds on the #1234 implementation, which introduces the shared normalizer and host-policy split.
48d59b8d,ef8ad67f,9d9fb273and2dfe7e7b.d8f04390…ee5715f1) are cherry-picks of Make creative click rewriting a separate switch #1252 with identical patch IDs, and are reviewed there.spec/1231-creative-asset-host-allowlist(**Spec**: creative asset host allowlist #1248).mainand only the four Add a host allowlist for creative asset rewriting #1231 commits remain.Changes
These are the #1231 commits only.
crates/trusted-server-core/src/settings.rsinclude_domainsfield (left out of the blob when empty), load-time normalization, strictvalidate_include_domains, and the include clause inshould_proxy_assetviais_host_permitted; testscrates/trusted-server-core/src/creative.rslink[href]no longer rewritesimagesrcset; the[imagesrcset]handler rewrites it once. Tests and module docscrates/trusted-server-core/src/proxy.rsis_host_permittedmadepub(crate); sign decline log; an 8-case GET/POST sign matrix covering200,502and403crates/trusted-server-core/src/config_payload.rsdocs/guide/configuration.md,creative-processing.md,first-party-proxy.md,api-reference.md502. Fixes the*.cdn.example.comapex exampletrusted-server.example.tomlinclude_domainsexampleCHANGELOG.mdinclude_domains; Fixed the inlineimagesrcsetdouble proxyCloses
Closes #1231
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 serveimagesrcsetregression test was seen failing on the unfixed code. Make creative click rewriting a separate switch #1252's switch-matrix tests pass together with this branch's allowlist tests.Rollout: deploy the binary first, then add
include_domainsand runts config push. Rollback: removeinclude_domainsand push first, then roll back the binary; older binaries reject the key because ofdeny_unknown_fields.Hardening note:
include_domainsentry fails settings validation with a config error rather than a panic. Covered byrewrite_include_domains_reject_malformed_entries.exclude_domainsentries: empty and bare"*"entries are dropped with a warning instead of rejected, so existing configs keep loading after an upgrade.Checklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)