diff --git a/.env.dev b/.env.dev
index c74756ed2f..63b879d7c6 100644
--- a/.env.dev
+++ b/.env.dev
@@ -1,4 +1,5 @@
-# Non-secret development overlays used while generating the Axum config blob.
+# Non-secret development overlays used while generating local config blobs.
+# The public endpoint defaults to Viceroy; the Axum quickstart overrides it.
# Sourcing this file alone does not configure the Axum server: also export the
# blob and referenced secret-store values as shown in docs/guide/getting-started.md.
@@ -6,6 +7,8 @@
TRUSTED_SERVER__PUBLISHER__DOMAIN=localhost
TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=localhost
TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=http://localhost:9090
+# Browser-facing endpoint for rewritten creative URLs, not the upstream origin.
+TRUSTED_SERVER__PUBLISHER__PUBLIC_ORIGIN=http://localhost:7676
# [synthetic]
TRUSTED_SERVER__SYNTHETIC__COUNTER_STORE=counter_store
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 8500af3508..ab3e4ed991 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -254,6 +254,8 @@ jobs:
# once the pinned Playwright version supports the newer image.
runs-on: ubuntu-24.04
timeout-minutes: 15
+ env:
+ VICEROY_CONFIG_GENERATOR_PATH: ${{ github.workspace }}/target/debug/generate-viceroy-config
steps:
- uses: actions/checkout@v4
@@ -297,6 +299,12 @@ jobs:
npm ci
npx playwright install --with-deps chromium
+ # startViceroy retargets the config after choosing its listener port. Build
+ # on this runner rather than relying on an uncached binary or a native
+ # artifact from prepare-artifacts' potentially newer Ubuntu image.
+ - name: Build browser config generator
+ run: cargo build --package trusted-server-integration-tests --bin generate-viceroy-config
+
- name: Test initial render fixture escaping
working-directory: crates/trusted-server-integration-tests/browser
run: node --test initial-render/pages.test.cjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4d5d0b9ea8..11a873f9e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **Breaking:** Creative rewrite proxy, click, CSS, `srcset`, sign-response, rebuild-response, and injected creative TSJS URLs are now absolute on `publisher.public_origin`. Review the [default authority, cookie scope, and coordinated upgrade/rollback warning for `public_origin`](docs/guide/configuration.md#publisher-configuration) before deployment. Existing root-relative signed proxy and click inputs remain accepted. TSJS bundle selection and hash calculation are unchanged.
- The Lockr browser module no longer polls for private SDK state or mutates its API host, removing the "Lockr SDK not detected" timeout warning (#1247). Root-relative Lockr XHR API requests and rewritten SDK URLs now stay on the page origin even with an external ``. Custom or unreplaced legacy SDKs that relied on the removed host rewrite must route their own API calls through `/integrations/lockr/api`; `rewrite_sdk = false` leaves static SDK tags unchanged but does not disable dynamic interception. See the [Lockr migration guide](docs/guide/integrations/lockr.md#migration-from-the-sdk-host-shim).
- Auction response collection now bounds network waiting by `[auction].timeout_ms`
measured from dispatch, including delayed headers and trickling bodies on all
diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs
index ce06ce7e30..69675e2669 100644
--- a/crates/trusted-server-adapter-fastly/src/app.rs
+++ b/crates/trusted-server-adapter-fastly/src/app.rs
@@ -1965,6 +1965,20 @@ mod tests {
);
}
+ #[test]
+ fn proxy_rebuild_registers_get_and_post() {
+ let route = NAMED_ROUTES
+ .iter()
+ .find(|route| route.path == "/first-party/proxy-rebuild")
+ .expect("should register proxy rebuild route");
+ assert!(matches!(
+ route.handler,
+ NamedRouteHandler::FirstPartyProxyRebuild
+ ));
+ assert!(route.primary_methods.contains(&Method::GET));
+ assert!(route.primary_methods.contains(&Method::POST));
+ }
+
#[test]
fn legacy_admin_aliases_route_to_local_deny_not_key_handlers() {
// Security guard for the legacy non-`/_ts` admin aliases. They must be
diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs
index 613832b059..6fa45aa6fa 100644
--- a/crates/trusted-server-adapter-spin/tests/routes.rs
+++ b/crates/trusted-server-adapter-spin/tests/routes.rs
@@ -805,21 +805,25 @@ async fn first_party_proxy_round_trip_through_spin_router() {
.to_vec(),
)
.expect("sign response body should be UTF-8");
- let href = json_string_field(&sign_body, "href")
- .expect("sign response must include a signed href path");
+ let href =
+ json_string_field(&sign_body, "href").expect("sign response must include a signed href");
+ let (_, authority_and_path) = href
+ .split_once("://")
+ .expect("should return an absolute signed href");
+ let (_, path_and_query) = authority_and_path
+ .split_once('/')
+ .expect("should include a path in the signed href");
+ let href_path_and_query = format!("/{path_and_query}");
assert!(
- href.starts_with("/first-party/proxy?"),
- "signed href must target the proxy path, got: {href}"
+ href_path_and_query.starts_with("/first-party/proxy?"),
+ "signed href must target the proxy path"
);
let router = test_router();
let proxy_req = request_builder()
.method("GET")
- .uri(href.clone())
- .header(
- "spin-full-url",
- format!("https://www.publisher.example{href}"),
- )
+ .uri(href_path_and_query)
+ .header("spin-full-url", href)
.body(edgezero_core::body::Body::empty())
.expect("should build proxy request");
let proxy_resp = route(router, proxy_req).await;
diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs
index 85a3807168..1f777bfeca 100644
--- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs
+++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs
@@ -415,6 +415,12 @@ fn build_draft_config_with_generator(
"origin_url",
&format!("origin_url = \"{origin}\""),
)?;
+ draft = replace_key_in_section(
+ &draft,
+ "publisher",
+ "public_origin",
+ &format!("public_origin = \"{origin}\""),
+ )?;
let detected = artifact
.detected_integrations
@@ -2787,6 +2793,7 @@ mod tests {
assert!(draft.contains("domain = \"www.publisher.example\""));
assert!(draft.contains("cookie_domain = \".www.publisher.example\""));
assert!(draft.contains("origin_url = \"https://www.publisher.example:8443\""));
+ assert!(draft.contains("public_origin = \"https://www.publisher.example:8443\""));
assert!(draft.contains("[integrations.gpt]\nenabled = true"));
assert!(draft.contains("[integrations.google_tag_manager]\nenabled = true"));
assert!(draft.contains("container_id = \"GTM-ABC123\""));
@@ -4002,6 +4009,10 @@ mod tests {
"https://origin.example.com",
"https://origin.publisher.example.com",
)
+ .replace(
+ "https://ads.example.com",
+ "https://ads.publisher.example.com",
+ )
}
/// A three-page site whose header ad unit tracks the section, so the crawl
diff --git a/crates/trusted-server-cli/src/commands/audit/generate/validate.rs b/crates/trusted-server-cli/src/commands/audit/generate/validate.rs
index a12e135100..9663fba9b5 100644
--- a/crates/trusted-server-cli/src/commands/audit/generate/validate.rs
+++ b/crates/trusted-server-cli/src/commands/audit/generate/validate.rs
@@ -77,6 +77,10 @@ mod tests {
"https://origin.example.com",
"https://origin.publisher.example.com",
)
+ .replace(
+ "https://ads.example.com",
+ "https://ads.publisher.example.com",
+ )
}
#[test]
diff --git a/crates/trusted-server-cli/tests/audit_config_diagnostics.rs b/crates/trusted-server-cli/tests/audit_config_diagnostics.rs
index 74e9e13167..4aa308de32 100644
--- a/crates/trusted-server-cli/tests/audit_config_diagnostics.rs
+++ b/crates/trusted-server-cli/tests/audit_config_diagnostics.rs
@@ -81,6 +81,10 @@ fn baseline() -> String {
"https://origin.example.com",
"https://origin.publisher.example.com",
)
+ .replace(
+ "https://ads.example.com",
+ "https://ads.publisher.example.com",
+ )
}
const GPT_PAGE: &str = r#"
AdExcludedMail"#;
const MATRIX_DEFAULT_INLINE_OUTPUT: &str = r#"
AdExcludedMail"#;
@@ -2022,15 +2073,31 @@ mod tests {
#[test]
fn default_settings_rewrite_matrix_fixture_byte_for_byte_as_before() {
let settings = matrix_settings(true, None);
+ let auction_expected = MATRIX_DEFAULT_AUCTION_OUTPUT
+ .replace(
+ "/first-party/",
+ &format!("https://{}/first-party/", settings.publisher.domain),
+ )
+ .replace(
+ "/static/tsjs=",
+ &format!("https://{}/static/tsjs=", settings.publisher.domain),
+ )
+ .replace(
+ "id=\"trustedserver-js\"",
+ &format!(
+ "id=\"trustedserver-js\" data-ts-public-origin=\"{}\"",
+ settings.publisher.effective_public_origin()
+ ),
+ );
for (path, expected) in [
- (CreativePath::Auction, MATRIX_DEFAULT_AUCTION_OUTPUT),
+ (CreativePath::Auction, auction_expected.as_str()),
(CreativePath::Inline, MATRIX_DEFAULT_INLINE_OUTPUT),
] {
assert_eq!(
process_for_path(&settings, path, MATRIX_FIXTURE_WITH_BODY),
expected,
- "{path:?}: default settings should match the pre-rewrite_clicks output exactly"
+ "{path:?}: should preserve the policy baseline with absolute generated endpoints"
);
}
}
@@ -2102,7 +2169,11 @@ mod tests {
fn rewrite_style_urls_handles_absolute_and_relative() {
let settings = crate::test_support::tests::create_test_settings();
let css = "background:url(https://cdn.example/a.png) no-repeat; mask: url('//cdn.example/m.svg') 0 0 / cover; border-image: url(/local/border.png) 30";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
// Absolute and protocol-relative rewritten
assert!(
out.matches("/first-party/proxy?tsurl=").count() >= 2,
@@ -2117,7 +2188,11 @@ mod tests {
fn rewrite_style_urls_handles_unterminated_quote_before_multibyte() {
let settings = crate::test_support::tests::create_test_settings();
let css = "background:url(\"café)";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
out, css,
"should leave an unterminated quoted url unchanged"
@@ -2128,8 +2203,16 @@ mod tests {
fn rewrite_style_urls_keeps_paren_inside_quoted_url() {
let settings = crate::test_support::tests::create_test_settings();
let css = "background:url(\"https://cdn.example/a)b.png\")";
- let out = rewrite_style_urls(&settings, css, "");
- let expected = super::build_proxy_url(&settings, "https://cdn.example/a)b.png", "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
+ let expected = super::build_proxy_url(
+ &settings,
+ "https://cdn.example/a)b.png",
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains(&expected),
"the whole quoted value, parens included, should be proxied: {out}"
@@ -2143,13 +2226,17 @@ mod tests {
// URL and it resolves with a literal quote in the path.
let css = r#"background:url("https://cdn.example/a\")b.png")"#;
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://cdn.example/a\")b.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy the resolved value: {out}"
);
@@ -2159,7 +2246,11 @@ mod tests {
fn rewrite_style_urls_ends_unquoted_url_at_first_paren() {
let settings = crate::test_support::tests::create_test_settings();
let css = "background:url(https://cdn.example/a)b.png)";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains("/first-party/proxy?tsurl="),
"an unquoted url still ends at the first paren: {out}"
@@ -2174,7 +2265,11 @@ mod tests {
fn rewrite_style_urls_leaves_string_broken_by_newline_unchanged() {
let settings = crate::test_support::tests::create_test_settings();
let css = "background:url(\"https://cdn.example/a\nb)";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
out, css,
"a string a newline has already ended should be left alone"
@@ -2193,7 +2288,11 @@ mod tests {
"background:url(\"https://cdn.example/a\\\r\nb.png\")",
"background:url(\"https://cdn.example/a\\\r\nb)c.png\")",
] {
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains("/first-party/proxy?tsurl="),
"an escaped absolute URL must still be proxied: {css} -> {out}"
@@ -2209,14 +2308,14 @@ mod tests {
let out = rewrite_style_urls(
&settings,
"background:url(\"https://tracker.example/\\70 ixel.gif\")",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/pixel.gif",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy the resolved URL: {out}"
);
@@ -2230,7 +2329,11 @@ mod tests {
// is never requested.
let css = "background:url(\"https://cdn.example/a\u{c}b.png\")";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(out, css, "should not proxy a value the browser discards");
}
@@ -2240,7 +2343,11 @@ mod tests {
let settings = crate::test_support::tests::create_test_settings();
let css = "background:url(\"https://cdn.example/plain.png\")";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains("/first-party/proxy?tsurl="),
@@ -2258,7 +2365,11 @@ mod tests {
let css = "a{background:url(\"https://cdn.example/a.png);\
b{background:url(\"https://cdn.example/c.png\")}";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(out, css, "should not invent a value out of a bad string");
assert!(
@@ -2275,7 +2386,7 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"a{background:url('https://tracker.example/pixel.gif'",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
@@ -2345,7 +2456,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
css.push_str(alphabet[(next() as usize) % alphabet.len()]);
}
- let out = rewrite_style_urls(&settings, &css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ &css,
+ &settings.publisher.effective_public_origin(),
+ );
// Output either keeps the original bytes, is rejected outright,
// or carries the proxy token it was rewritten for. Anything else
@@ -2366,13 +2481,17 @@ b{background:url(\"https://cdn.example/c.png\")}";
"background:image-set(\"https://tracker.example/a.png\" 1x)",
"background:-webkit-image-set(\"https://tracker.example/a.png\" 1x)",
] {
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy a bare image-set candidate: {css} -> {out}"
);
@@ -2385,7 +2504,7 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"background:image-set(\"https://tracker.example/a.png\" 1x,\"https://tracker.example/b.png\" 2x)",
- "",
+ &settings.publisher.effective_public_origin(),
);
for name in ["a.png", "b.png"] {
@@ -2393,7 +2512,7 @@ b{background:url(\"https://cdn.example/c.png\")}";
out.contains(&super::build_proxy_url(
&settings,
&format!("https://tracker.example/{name}"),
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy {name}: {out}"
);
@@ -2406,14 +2525,14 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"@font-face{src:src(\"https://tracker.example/a.woff2\")}",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/a.woff2",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy a src() argument: {out}"
);
@@ -2423,13 +2542,17 @@ b{background:url(\"https://cdn.example/c.png\")}";
fn rewrite_style_urls_proxies_a_bare_import_prelude() {
let settings = crate::test_support::tests::create_test_settings();
// `@import "…";` loads a whole third-party stylesheet.
- let out = rewrite_style_urls(&settings, "@import \"https://tracker.example/s.css\";", "");
+ let out = rewrite_style_urls(
+ &settings,
+ "@import \"https://tracker.example/s.css\";",
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/s.css",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy an @import prelude string: {out}"
);
@@ -2447,7 +2570,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
"@media \"https://tracker.example/a.png\"{a{color:red}}",
"@import \"https://tracker.example/s.css\";content:\"https://tracker.example/a.png\"",
] {
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
!out.contains("content:\"/first-party")
@@ -2468,7 +2595,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
let css = "@import \"https://tracker.example/x.css\"";
let attribute = super::rewrite_style_attribute_urls(&settings, css, "");
- let stylesheet = rewrite_style_urls(&settings, css, "");
+ let stylesheet = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
attribute, css,
@@ -2478,7 +2609,7 @@ b{background:url(\"https://cdn.example/c.png\")}";
stylesheet.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/x.css",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should still read the same bytes as a rule in a stylesheet: {stylesheet}"
);
@@ -2492,14 +2623,14 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"@import \"https://cdn.example/x.css\" \"https://tracker.example/z.css\";",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://cdn.example/x.css",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy the prelude's URL: {out}"
);
@@ -2516,7 +2647,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
// following string sits inside the at-rule. It is still not a URL, and
// rewriting it would rewrite a `content` value.
let css = "@import \"https://cdn.example/x.css\" content:\"https://legit.example/y.png\"";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
!out.contains("content:url("),
@@ -2534,7 +2669,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
let settings = crate::test_support::tests::create_test_settings();
let css = ".a{--metadata:@import \"https://tracker.example/text\";}";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
out, css,
@@ -2547,7 +2686,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
let settings = crate::test_support::tests::create_test_settings();
let css = "@unknown @import \"https://tracker.example/text\";";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
out, css,
@@ -2561,14 +2704,14 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"@import /*c*/ \"https://cdn.example/x.css\";",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://cdn.example/x.css",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should not spend the prelude's value on whitespace: {out}"
);
@@ -2582,13 +2725,17 @@ b{background:url(\"https://cdn.example/c.png\")}";
" @import \"https://cdn.example/x.css\";",
] {
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://cdn.example/x.css",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should ignore a top-level CDO/CDC token before @import: {css} -> {out}"
);
@@ -2603,11 +2750,19 @@ b{background:url(\"https://cdn.example/c.png\")}";
"a{".repeat(super::MAX_CSS_NESTING_DEPTH),
"}".repeat(super::MAX_CSS_NESTING_DEPTH)
);
- let out = rewrite_style_urls(&settings, &css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ &css,
+ &settings.publisher.effective_public_origin(),
+ );
for url in ["https://cdn.example/x.css", "https://tracker.example/y.png"] {
assert!(
- out.contains(&super::build_proxy_url(&settings, url, "")),
+ out.contains(&super::build_proxy_url(
+ &settings,
+ url,
+ &settings.publisher.effective_public_origin()
+ )),
"should proxy {url} without treating later CSS as part of the import: {out}"
);
}
@@ -2630,13 +2785,17 @@ b{background:url(\"https://cdn.example/c.png\")}";
"}".repeat(blocks)
);
- let out = rewrite_style_urls(&settings, &css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ &css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
out.contains(&super::build_proxy_url(
&settings,
"https://cdn.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
admitted,
"image-set() at {blocks} nested blocks should be admitted: {admitted}"
@@ -2652,14 +2811,14 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"background:image-set(@x \"https://tracker.example/a.png\" 1x)",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy a candidate following an unexpected token: {out}"
);
@@ -2674,7 +2833,7 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"background-image:src(\"https://tracker.example/a.png\")",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
@@ -2685,7 +2844,7 @@ b{background:url(\"https://cdn.example/c.png\")}";
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should still proxy the value: {out}"
);
@@ -2699,14 +2858,14 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"background-image:image-set(var(--missing, \"https://tracker.example/a.png\") 1x)",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy a var() fallback candidate: {out}"
);
@@ -2721,7 +2880,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
"content:var(--missing, \"https://tracker.example/a.png\")",
"@import var(--missing, \"https://tracker.example/x.css\");",
] {
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
out, css,
@@ -2739,19 +2902,23 @@ b{background:url(\"https://cdn.example/c.png\")}";
let indirect = rewrite_style_urls(
&settings,
"a{--c:\"https://tracker.example/a.png\";background-image:image-set(var(--c) 1x)}",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
!indirect.contains("/first-party/proxy?tsurl="),
"substitution is out of reach, so nothing is claimed: {indirect}"
);
- let token = rewrite_style_urls(&settings, "a{--c:url(https://tracker.example/a.png)}", "");
+ let token = rewrite_style_urls(
+ &settings,
+ "a{--c:url(https://tracker.example/a.png)}",
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
token.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"a url() token in a custom property is still a URL: {token}"
);
@@ -2765,14 +2932,14 @@ b{background:url(\"https://cdn.example/c.png\")}";
let out = rewrite_style_urls(
&settings,
"@font-face{src:src(var(--missing, \"https://tracker.example/a.woff2\"))}",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/a.woff2",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy the fallback: {out}"
);
@@ -2791,7 +2958,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
// never the URL it requests, and rewriting it would claim otherwise.
let css = "background:url(var(--missing, \"https://tracker.example/a.png\"))";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(out, css, "should leave a url() argument unsubstituted");
}
@@ -2804,15 +2975,18 @@ b{background:url(\"https://cdn.example/c.png\")}";
let candidate = rewrite_style_urls(
&settings,
"background:image-set(\"https://tracker.example/a.png\" 1x)",
- "",
+ &settings.publisher.effective_public_origin(),
);
assert!(
candidate.starts_with("background:image-set(\"") && !candidate.contains("set(url("),
"an image-set candidate stays a string: {candidate}"
);
- let prelude =
- rewrite_style_urls(&settings, "@import \"https://tracker.example/s.css\";", "");
+ let prelude = rewrite_style_urls(
+ &settings,
+ "@import \"https://tracker.example/s.css\";",
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
prelude.starts_with("@import \"") && !prelude.contains("@import url("),
"an @import prelude stays a string: {prelude}"
@@ -2829,13 +3003,17 @@ b{background:url(\"https://cdn.example/c.png\")}";
"background:image-set(env(--nope, \"https://tracker.example/a.png\") 1x)",
"background:image-set(env(--a, var(--b, \"https://tracker.example/a.png\")) 1x)",
] {
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://tracker.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should proxy a substituted fallback: {css} -> {out}"
);
@@ -2852,7 +3030,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
"background:image-set(linear-gradient(\"https://tracker.example/a.png\") 1x)",
"background:image-set(counter(x, \"https://tracker.example/a.png\") 1x)",
] {
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
out, css,
@@ -2868,7 +3050,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
"content:env(--nope, \"https://tracker.example/a.png\")",
"background:url(env(--nope, \"https://tracker.example/a.png\"))",
] {
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(
out, css,
@@ -2884,7 +3070,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
// unbounded walk would abort the request rather than answer it.
let css = "{".repeat(super::MAX_CSS_NESTING_DEPTH * 40);
- let out = rewrite_style_urls(&settings, &css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ &css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.is_empty(),
@@ -2911,13 +3101,17 @@ b{background:url(\"https://cdn.example/c.png\")}";
"}".repeat(super::MAX_CSS_NESTING_DEPTH)
);
- let out = rewrite_style_urls(&settings, &css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ &css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://cdn.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should still rewrite {reference} at the deepest supported level: {out}"
);
@@ -2929,13 +3123,17 @@ b{background:url(\"https://cdn.example/c.png\")}";
let settings = crate::test_support::tests::create_test_settings();
let css = "@media screen{.a{background:url(https://cdn.example/a.png)}}";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(
out.contains(&super::build_proxy_url(
&settings,
"https://cdn.example/a.png",
- ""
+ &settings.publisher.effective_public_origin()
)),
"should rewrite a url() inside a media query: {out}"
);
@@ -2945,7 +3143,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
fn rewrite_style_urls_handles_mismatched_quotes() {
let settings = crate::test_support::tests::create_test_settings();
let css = "background:url('/local/a.png\")";
- let out = rewrite_style_urls(&settings, css, "");
+ let out = rewrite_style_urls(
+ &settings,
+ css,
+ &settings.publisher.effective_public_origin(),
+ );
assert_eq!(out, css, "should leave a mismatched quoted url unchanged");
}
@@ -3223,12 +3425,24 @@ b{background:url(\"https://cdn.example/c.png\")}";
let limit = 256;
let settings = crate::test_support::tests::create_test_settings();
let reference = "@import \"https://cdn.example.com/a.css\";";
- super::rewrite_style_urls_in_context(&settings, reference, "", true, limit)
- .expect("should fit the rewritten reference before appending the tail");
+ super::rewrite_style_urls_in_context(
+ &settings,
+ reference,
+ &settings.publisher.effective_public_origin(),
+ true,
+ limit,
+ )
+ .expect("should fit the rewritten reference before appending the tail");
let css = format!("{reference}{}", " ".repeat(limit - reference.len()));
assert!(
matches!(
- super::rewrite_style_urls_in_context(&settings, &css, "", true, limit),
+ super::rewrite_style_urls_in_context(
+ &settings,
+ &css,
+ &settings.publisher.effective_public_origin(),
+ true,
+ limit
+ ),
Err(super::CssRewriteError::OutputTooLarge)
),
"should reject the expanded output including the unchanged tail"
@@ -3270,7 +3484,7 @@ b{background:url(\"https://cdn.example/c.png\")}";
let rewritten = super::rewrite_style_urls_in_context(
&settings,
&css,
- "",
+ &settings.publisher.effective_public_origin(),
allows_import_rules,
limit,
);
@@ -3297,7 +3511,14 @@ b{background:url(\"https://cdn.example/c.png\")}";
let reference = "@import \"https://cdn.example.com/a.css\";";
let css = format!("{}{reference}", " ".repeat(limit - reference.len()));
assert!(
- super::rewrite_style_urls_in_context(&settings, &css, "", true, limit).is_err(),
+ super::rewrite_style_urls_in_context(
+ &settings,
+ &css,
+ &settings.publisher.effective_public_origin(),
+ true,
+ limit
+ )
+ .is_err(),
"should reject URL expansion beyond the output budget"
);
}
@@ -3331,7 +3552,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
fn rewrite_srcset_w_and_x_descriptors() {
let settings = crate::test_support::tests::create_test_settings();
let srcset = "https://cdn.example/a.png 100w, //cdn.example/b.png 2x, /local/c.png 1x";
- let out = rewrite_srcset(&settings, srcset, "");
+ let out = rewrite_srcset(
+ &settings,
+ srcset,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(out.contains(" 100w"));
assert!(out.contains(" 2x"));
assert!(out.contains("/local/c.png 1x"));
@@ -3346,7 +3571,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
fn rewrite_srcset_ignores_non_network_schemes() {
let settings = crate::test_support::tests::create_test_settings();
let srcset = "data:image/png;base64,AAAA 1x, https://cdn.example/a.png 2x";
- let out = rewrite_srcset(&settings, srcset, "");
+ let out = rewrite_srcset(
+ &settings,
+ srcset,
+ &settings.publisher.effective_public_origin(),
+ );
assert!(out.contains("data:image/png;base64,AAAA 1x"), "{}", out);
assert!(out.contains("/first-party/proxy?tsurl="), "{}", out);
}
@@ -3764,7 +3993,10 @@ b{background:url(\"https://cdn.example/c.png\")}";
"#;
let out = rewrite_creative_html(&settings, html);
- assert!(out.contains("data-src=\"/first-party/proxy?tsurl="));
+ assert!(out.contains(&format!(
+ "data-src=\"{}/first-party/proxy?tsurl=",
+ settings.publisher.effective_public_origin()
+ )));
assert!(out.matches("/first-party/proxy?tsurl=").count() >= 1);
// relative candidate remains
assert!(out.contains("/local/img.png 1x"));
@@ -3785,7 +4017,11 @@ b{background:url(\"https://cdn.example/c.png\")}";
"//cdnjs.publisher.example.org/lib.js",
] {
assert_eq!(
- proxy_if_abs(&settings, excluded, ""),
+ proxy_if_abs(
+ &settings,
+ excluded,
+ &settings.publisher.effective_public_origin()
+ ),
None,
"should leave excluded URL `{excluded}` unproxied"
);
@@ -3796,8 +4032,17 @@ b{background:url(\"https://cdn.example/c.png\")}";
"https://notpublisher.example.org/lib.js",
] {
assert!(
- proxy_if_abs(&settings, proxied, "")
- .is_some_and(|url| url.starts_with("/first-party/proxy?tsurl=")),
+ proxy_if_abs(
+ &settings,
+ proxied,
+ &settings.publisher.effective_public_origin()
+ )
+ .is_some_and(|url| {
+ url.starts_with(&format!(
+ "{}/first-party/proxy?tsurl=",
+ settings.publisher.effective_public_origin()
+ ))
+ }),
"should proxy non-excluded URL `{proxied}`"
);
}
@@ -4200,7 +4445,174 @@ b{background:url(\"https://cdn.example/c.png\")}";
// Non-excluded should be rewritten and SHOULD have data-tsclick
assert!(out.contains("/first-party/click?tsurl="));
assert!(out.contains("advertiser.example.com"));
- assert!(out.contains("data-tsclick=\"/first-party/click"));
+ assert!(out.contains(&format!(
+ "data-tsclick=\"{}/first-party/click",
+ settings.publisher.effective_public_origin()
+ )));
+ }
+
+ #[test]
+ fn creative_runtime_stamps_normalized_origin_for_documents_and_fragments() {
+ for configured in [
+ None,
+ Some("HTTPS://ADS.PUBLISHER.EXAMPLE:443"),
+ Some("https://ads.publisher.example:8443"),
+ ] {
+ let mut settings = crate::test_support::tests::create_test_settings();
+ settings.publisher.public_origin = configured.map(str::to_owned);
+ settings
+ .publisher
+ .normalize()
+ .expect("should normalize publisher settings");
+ let origin = settings.publisher.effective_public_origin();
+ for markup in ["ad", "ad
"] {
+ let output = rewrite_creative_html(&settings, markup);
+ let expected = format!(
+ ""
+ );
+ assert_eq!(
+ output.matches(&expected).count(),
+ 1,
+ "should stamp the same configured origin used for the creative runtime source"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn inline_origin_resolution_preserves_signatures_and_query_order() {
+ let clear = "https://cdn.example.com:9443/ad.png?b=two+words&a=1&a=2#fragment";
+ let expected_signed_target = "https://cdn.example.com:9443/ad.png?b=two+words&a=1&a=2";
+ for configured in [None, Some("https://ads.publisher.example:8443")] {
+ let mut settings = crate::test_support::tests::create_test_settings();
+ settings.publisher.public_origin = configured.map(str::to_owned);
+ for request_origin in [
+ "",
+ "http://localhost:7676",
+ "https://forwarded.publisher.example:8443",
+ ] {
+ let expected_origin = if request_origin.is_empty() {
+ settings.publisher.effective_public_origin()
+ } else {
+ request_origin.to_owned()
+ };
+ let output = rewrite_inline_creative_html(
+ &settings,
+ request_origin,
+ &format!("
ad"),
+ );
+ for (attribute, path) in [
+ ("src", "/first-party/proxy"),
+ ("href", "/first-party/click"),
+ ] {
+ let value = output
+ .split(&format!("{attribute}=\""))
+ .nth(1)
+ .expect("should emit attribute")
+ .split('"')
+ .next()
+ .expect("should close attribute");
+ let parsed = url::Url::parse(value).expect("should emit absolute URL");
+ assert_eq!(
+ parsed.origin().ascii_serialization(),
+ expected_origin,
+ "should retain inline origin precedence and empty fallback"
+ );
+ assert_eq!(parsed.path(), path, "should use the selected endpoint");
+ let pairs = parsed.query_pairs().collect::>();
+ assert_eq!(
+ pairs
+ .iter()
+ .map(|(key, _)| key.as_ref())
+ .collect::>(),
+ ["tsurl", "b", "a", "a", "tstoken"],
+ "should preserve target ordering and duplicates"
+ );
+ assert_eq!(
+ pairs[0].1, "https://cdn.example.com:9443/ad.png",
+ "should remove query and fragment from tsurl"
+ );
+ assert_eq!(
+ pairs[4].1,
+ crate::http_util::compute_encrypted_sha256_token(
+ &settings,
+ expected_signed_target,
+ ),
+ "should sign the unchanged target, not the chosen origin"
+ );
+ }
+ assert!(
+ !output.contains("trustedserver-js"),
+ "should preserve the inline runtime omission"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn generated_first_party_urls_use_public_origin() {
+ let mut settings = crate::test_support::tests::create_test_settings();
+ settings.publisher.public_origin = Some("https://ads.publisher.example:8443".to_string());
+
+ let proxy = build_proxy_url(
+ &settings,
+ "https://cdn.example.com/ad.png?campaign=1",
+ &settings.publisher.effective_public_origin(),
+ );
+ let click = build_click_url(
+ &settings,
+ "https://advertiser.example.com/landing",
+ &settings.publisher.effective_public_origin(),
+ );
+ for generated in [&proxy, &click] {
+ let parsed = url::Url::parse(generated).expect("should build an absolute URL");
+ assert_eq!(
+ parsed.origin().ascii_serialization(),
+ "https://ads.publisher.example:8443"
+ );
+ }
+
+ let out = rewrite_creative_html(
+ &settings,
+ r#"
ad"#,
+ );
+ assert!(out.contains("https://ads.publisher.example:8443/first-party/proxy?"));
+ assert!(out.contains("https://ads.publisher.example:8443/first-party/click?"));
+ assert_eq!(
+ out.matches("https://ads.publisher.example:8443/first-party/proxy?")
+ .count(),
+ 3,
+ "should qualify resource, srcset, and CSS rewrites"
+ );
+ assert!(out.contains("https://ads.publisher.example:8443/static/tsjs="));
+ assert!(!out.contains(&settings.publisher.origin_url));
+ assert!(!out.contains(&format!(
+ "https://{}/first-party",
+ settings.publisher.domain
+ )));
+
+ let fragment = rewrite_creative_html(&settings, "Body-less creative
");
+ assert_eq!(
+ fragment
+ .matches("https://ads.publisher.example:8443/static/tsjs=")
+ .count(),
+ 1,
+ "should inject the absolute runtime once into a body-less creative"
+ );
+ }
+
+ #[test]
+ fn generated_first_party_urls_fall_back_to_publisher_domain() {
+ let settings = crate::test_support::tests::create_test_settings();
+ let proxy = build_proxy_url(
+ &settings,
+ "https://cdn.example.com/ad.png",
+ &settings.publisher.effective_public_origin(),
+ );
+ assert!(proxy.starts_with(&format!(
+ "https://{}/first-party/proxy?",
+ settings.publisher.domain
+ )));
}
// ── sanitize_creative_html tests ────────────────────────────────────────
diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs
index 2edb32af96..3c5579be27 100644
--- a/crates/trusted-server-core/src/html_processor.rs
+++ b/crates/trusted-server-core/src/html_processor.rs
@@ -104,6 +104,10 @@ pub struct HtmlProcessorConfig {
pub origin_host: String,
pub request_host: String,
pub request_scheme: String,
+ /// Effective configured browser-facing origin, independent of the request origin.
+ ///
+ /// Use [`Self::from_settings`] to resolve the normalized setting or domain fallback.
+ pub public_origin: String,
pub integrations: IntegrationRegistry,
/// Pre-computed ``.
/// Injected at `` open. `None` when no slots matched.
@@ -147,6 +151,7 @@ impl HtmlProcessorConfig {
origin_host: origin_host.to_owned(),
request_host: request_host.to_owned(),
request_scheme: request_scheme.to_owned(),
+ public_origin: settings.publisher.effective_public_origin(),
integrations: integrations.clone(),
ad_slots_script: None,
ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)),
@@ -352,6 +357,7 @@ pub(crate) fn create_html_processor_with_document_state(
let ad_slots_script = ad_slots_script.clone();
let gpt_diagnostics = gpt_diagnostics.clone();
let trace_bootstrap = trace_bootstrap.clone();
+ let public_origin = config.public_origin.clone();
move |el| {
if !injected_tsjs.get() {
let mut snippet = String::new();
@@ -382,9 +388,10 @@ pub(crate) fn create_html_processor_with_document_state(
// Main bundle: core + non-deferred integrations (synchronous).
let immediate_ids = integrations.js_module_ids_immediate();
let script_attributes = integrations.tsjs_script_tag_attributes();
- snippet.push_str(&tsjs::tsjs_script_tag_with_attributes(
- &immediate_ids,
+ snippet.push_str(&tsjs::tsjs_script_tag_with_public_origin(
+ &tsjs::tsjs_script_src(&immediate_ids),
&script_attributes,
+ &public_origin,
));
// Active diagnostics loads synchronously after core so its
// GPT listeners precede publisher scripts in the origin head.
@@ -826,6 +833,7 @@ mod tests {
fn create_test_config() -> HtmlProcessorConfig {
HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::None,
origin_host: "origin.example.com".to_owned(),
@@ -1817,6 +1825,7 @@ mod tests {
#[test]
fn injects_ad_slots_at_head_open() {
let config = HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::None,
origin_host: "origin.example.com".to_string(),
@@ -1855,6 +1864,58 @@ mod tests {
);
}
+ #[test]
+ fn publisher_runtime_stamps_normalized_configured_origin_without_changing_bundle() {
+ for (configured, expected) in [
+ (
+ Some("HTTPS://ADS.PUBLISHER.EXAMPLE:443"),
+ "https://ads.publisher.example",
+ ),
+ (
+ Some("https://ads.publisher.example:8443"),
+ "https://ads.publisher.example:8443",
+ ),
+ (None, "https://publisher.example.com"),
+ ] {
+ let mut settings = create_test_settings();
+ settings.publisher.domain = "publisher.example.com".to_string();
+ settings.publisher.public_origin = configured.map(str::to_owned);
+ settings
+ .publisher
+ .normalize()
+ .expect("should normalize publisher settings");
+ let integrations = IntegrationRegistry::empty_for_tests();
+ let config = HtmlProcessorConfig::from_settings(
+ &settings,
+ &integrations,
+ "upstream.example.com",
+ "www.publisher.example.com",
+ "https",
+ );
+ assert_eq!(
+ config.public_origin, expected,
+ "should resolve the configured origin once"
+ );
+ let mut processor = create_html_processor(config);
+ let output = processor
+ .process_chunk(b"", true)
+ .expect("should process publisher HTML");
+ let output = String::from_utf8(output).expect("should return UTF-8");
+ let src = tsjs::tsjs_script_src(&integrations.js_module_ids_immediate());
+ assert!(
+ output.contains(&format!("src=\"{src}\"")),
+ "should preserve the root-relative source and exact module hash"
+ );
+ assert_eq!(
+ output
+ .matches(&format!("data-ts-public-origin=\"{expected}\""))
+ .count(),
+ 1,
+ "should stamp the effective setting rather than request or upstream origin"
+ );
+ }
+ }
+
#[test]
fn golden_script_tag_injected_at_head_start() {
// The trusted-server script tag must be the FIRST child of .
@@ -1896,6 +1957,7 @@ mod tests {
let bids_script = r#""#;
let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string())));
let config = HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::InlineBids,
origin_host: "origin.example.com".to_string(),
@@ -1936,6 +1998,7 @@ mod tests {
let bids_script = r#""#;
let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string())));
let config = HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::InlineBids,
origin_host: "origin.example.com".to_string(),
@@ -1977,6 +2040,7 @@ mod tests {
let request_host = "proxy.test-publisher.example.com";
let config = HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::None,
origin_host: "origin.test-publisher.example.com".to_string(),
@@ -2032,6 +2096,7 @@ mod tests {
// (state is None) — e.g. auction timed out with zero bids. Fallback to {}.
let state = std::sync::Arc::new(std::sync::Mutex::new(None));
let config = HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::InlineBids,
origin_host: "origin.example.com".to_string(),
@@ -2065,6 +2130,7 @@ mod tests {
// unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified").
let state = std::sync::Arc::new(std::sync::Mutex::new(None));
let config = HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::None,
origin_host: "origin.example.com".to_string(),
@@ -2091,6 +2157,7 @@ mod tests {
fn marker_mode_config(marker: &str, observer: Option>) -> HtmlProcessorConfig {
HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: observer,
body_close: BodyCloseInjection::Marker(marker.to_string()),
origin_host: "origin.example.com".to_string(),
@@ -2353,6 +2420,7 @@ mod tests {
fn bodyless_marker_mode_emits_an_owned_terminal_seam_even_after_source_bytes() {
const MARKER: &str = "";
let config = HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::Marker(MARKER.to_string()),
origin_host: "origin.example.com".to_string(),
diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs
index bb1a875e8f..c8a61b9388 100644
--- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs
+++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs
@@ -629,6 +629,7 @@ mod tests {
fn process_html_with_registry(html: &str, integrations: IntegrationRegistry) -> String {
let processor = create_html_processor(HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
body_close: BodyCloseInjection::None,
origin_host: "origin.example.com".to_string(),
diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs
index 54901ae412..70e36f10e5 100644
--- a/crates/trusted-server-core/src/proxy.rs
+++ b/crates/trusted-server-core/src/proxy.rs
@@ -20,7 +20,7 @@ use crate::constants::{
HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER,
HEADER_USER_AGENT, HEADER_X_FORWARDED_FOR,
};
-use crate::creative::{CreativeCssProcessor, CreativeHtmlProcessor};
+use crate::creative::{CreativeCssProcessor, CreativeHtmlProcessor, first_party_url};
use crate::edge_cookie::get_ec_id;
use crate::error::TrustedServerError;
use crate::platform::{
@@ -1794,7 +1794,13 @@ pub async fn handle_first_party_proxy_sign(
let mut base = target.clone();
base.set_query(None);
base.set_fragment(None);
- let proxied = crate::creative::build_proxy_url_with_extras(settings, target.as_str(), &extras);
+ let public_origin = settings.publisher.effective_public_origin();
+ let proxied = crate::creative::build_proxy_url_with_extras(
+ settings,
+ target.as_str(),
+ &public_origin,
+ &extras,
+ );
let resp = ProxySignResp {
href: proxied,
@@ -1872,7 +1878,7 @@ struct ProxyRebuildResp {
}
/// Proxy rebuild endpoint.
-/// POST /first-party/proxy-rebuild
+/// POST or GET /first-party/proxy-rebuild
/// Body: { tsclick: "/first-party/click?tsurl=...&a=1", add: {"b":"2"}, del: ["c"] }
/// - Only allows adding new parameters or removing existing ones.
/// - Base tsurl cannot change.
@@ -1929,17 +1935,14 @@ pub async fn handle_first_party_proxy_rebuild(
rebuild_request_from_pairs(url::form_urlencoded::parse(req_query.as_bytes()))?
};
- // Accept both the root-relative form the rewriter emits and an absolute
- // first-party URL: the client may have absolutized the click to keep it
- // resolvable inside a `srcdoc` frame. Concatenating a dummy origin would
- // mangle the absolute form, so parse it properly instead. The signature
- // covers `tsurl` plus params only, never the origin, so both forms validate
- // identically.
+ // Input parsing is independent of the configured output origin. The input
+ // authority is discarded: signatures cover only tsurl and its params, and
+ // the rebuilt href always uses the configured public origin.
let c_url =
parse_request_target(&payload.tsclick).change_context(TrustedServerError::Proxy {
message: "invalid tsclick".to_string(),
})?;
- if c_url.path() != "/first-party/click" {
+ if !matches!(c_url.scheme(), "http" | "https") || c_url.path() != "/first-party/click" {
return Err(Report::new(TrustedServerError::Proxy {
message: "invalid tsclick path".to_string(),
}));
@@ -2033,7 +2036,11 @@ pub async fn handle_first_party_proxy_rebuild(
qs.append_pair(k, v);
}
qs.append_pair("tstoken", &token);
- let href = format!("/first-party/click?{}", qs.finish());
+ let public_origin = settings.publisher.effective_public_origin();
+ let href = first_party_url(
+ &public_origin,
+ &format!("/first-party/click?{}", qs.finish()),
+ );
// Compute diagnostics: added and removed
let mut added: std::collections::BTreeMap = std::collections::BTreeMap::new();
@@ -2578,7 +2585,9 @@ mod tests {
#[test]
fn proxy_sign_returns_signed_url() {
futures::executor::block_on(async {
- let settings = create_test_settings();
+ let mut settings = create_test_settings();
+ settings.publisher.public_origin =
+ Some("https://ads.publisher.example:8443".to_string());
let body = serde_json::json!({
"url": "https://cdn.example/asset.js?c=3&b=2",
});
@@ -2588,7 +2597,11 @@ mod tests {
.expect("sign ok");
assert_eq!(resp.status(), StatusCode::OK);
let json = response_body_string(resp);
- assert!(json.contains("/first-party/proxy?tsurl="), "{}", json);
+ assert!(
+ json.contains("https://ads.publisher.example:8443/first-party/proxy?tsurl="),
+ "{}",
+ json
+ );
assert!(json.contains("tsexp"), "{}", json);
assert!(
json.contains("\"base\":\"https://cdn.example/asset.js\""),
@@ -2698,8 +2711,12 @@ mod tests {
.as_str()
.expect("should include string href in sign response");
let signed_url =
- url::Url::parse(&format!("https://edge.example.com{href}"))
- .expect("should parse signed proxy URL");
+ url::Url::parse(href).expect("should parse absolute signed proxy URL");
+ assert_eq!(
+ signed_url.origin().ascii_serialization(),
+ settings.publisher.effective_public_origin(),
+ "{label} should use the configured public origin"
+ );
assert_eq!(
signed_url.path(),
"/first-party/proxy",
@@ -3244,6 +3261,106 @@ mod tests {
});
}
+ #[test]
+ fn proxy_rebuild_accepts_legacy_and_absolute_clicks_and_canonicalizes_output() {
+ futures::executor::block_on(async {
+ let mut settings = create_test_settings();
+ settings.publisher.public_origin =
+ Some("https://ads.publisher.example:8443".to_string());
+ let tsurl = "https://advertiser.example.com/landing";
+ let token = crate::http_util::compute_encrypted_sha256_token(&settings, tsurl);
+ let legacy = format!(
+ "/first-party/click?tsurl={}&tstoken={token}",
+ url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::()
+ );
+
+ for tsclick in [
+ legacy.clone(),
+ format!("https://alias.example{legacy}"),
+ format!("//alias.example{legacy}"),
+ format!(r"\\alias.example{legacy}"),
+ format!(r"/\alias.example{legacy}"),
+ format!(" //alias.example{legacy}"),
+ format!("\t//alias.example{legacy}"),
+ format!("https:/alias.example{legacy}"),
+ ] {
+ let body = serde_json::json!({ "tsclick": tsclick });
+ let req = HttpRequest::builder()
+ .method(Method::POST)
+ .uri("https://edge.example/first-party/proxy-rebuild")
+ .body(EdgeBody::from(
+ serde_json::to_string(&body).expect("should serialize test JSON"),
+ ))
+ .expect("should build rebuild request");
+ let response = handle_first_party_proxy_rebuild(&settings, &noop_services(), req)
+ .await
+ .expect("should rebuild a valid click");
+ let body: serde_json::Value = serde_json::from_str(&response_body_string(response))
+ .expect("should return JSON");
+ assert!(
+ body["href"]
+ .as_str()
+ .expect("should return href")
+ .starts_with("https://ads.publisher.example:8443/first-party/click?"),
+ "should canonicalize to public_origin"
+ );
+ }
+
+ let query = url::form_urlencoded::Serializer::new(String::new())
+ .append_pair("tsclick", &legacy)
+ .finish();
+ let req = build_http_request(
+ Method::GET,
+ format!("https://edge.example/first-party/proxy-rebuild?{query}"),
+ );
+ let response = handle_first_party_proxy_rebuild(&settings, &noop_services(), req)
+ .await
+ .expect("should rebuild legacy GET click");
+ assert_eq!(response.status(), StatusCode::FOUND);
+ assert!(
+ response_header(&response, header::LOCATION)
+ .expect("should return a Location header")
+ .starts_with("https://ads.publisher.example:8443/first-party/click?")
+ );
+ });
+ }
+
+ #[test]
+ fn proxy_rebuild_rejects_invalid_click_schemes_and_paths_with_valid_tokens() {
+ futures::executor::block_on(async {
+ let settings = create_test_settings();
+ let signed = signed_click_target(&settings);
+ for tsclick in [
+ format!("ftp://edge.example{signed}"),
+ format!("data:{signed}"),
+ format!(
+ "https://edge.example{}",
+ signed.replace("/first-party/click", "/first-party/click-extra")
+ ),
+ format!(
+ "https://edge.example{}",
+ signed.replace("/first-party/click", "/other-path")
+ ),
+ ] {
+ let body = serde_json::json!({ "tsclick": tsclick });
+ let req = HttpRequest::builder()
+ .method(Method::POST)
+ .uri("https://edge.example/first-party/proxy-rebuild")
+ .body(EdgeBody::from(
+ serde_json::to_string(&body).expect("should serialize test JSON"),
+ ))
+ .expect("should build rebuild request");
+ let err = handle_first_party_proxy_rebuild(&settings, &noop_services(), req)
+ .await
+ .expect_err("should reject the scheme or path despite a valid token");
+ assert!(
+ err.to_string().contains("invalid tsclick path"),
+ "should reject {tsclick:?} before token validation: {err:?}"
+ );
+ }
+ });
+ }
+
// Build a signed `/first-party/click` target with one extra param.
fn signed_click_target(settings: &crate::settings::Settings) -> String {
let tsurl = "https://cdn.example/landing.html";
@@ -3457,7 +3574,10 @@ mod tests {
.and_then(|v| v.to_str().ok())
.expect("should carry a Location header");
assert!(
- location.starts_with("/first-party/click?tsurl="),
+ location.starts_with(&format!(
+ "{}/first-party/click?tsurl=",
+ settings.publisher.effective_public_origin()
+ )),
"should redirect to the rebuilt first-party click: {location}"
);
assert!(
@@ -3506,7 +3626,10 @@ mod tests {
.and_then(|v| v.to_str().ok())
.expect("should carry a Location header");
assert!(
- location.starts_with("/first-party/click?tsurl="),
+ location.starts_with(&format!(
+ "{}/first-party/click?tsurl=",
+ settings.publisher.effective_public_origin()
+ )),
"should redirect to the rebuilt click: {location}"
);
assert!(
@@ -3539,7 +3662,10 @@ mod tests {
.expect("JSON rebuild should succeed");
assert_eq!(resp.status(), StatusCode::OK);
- assert!(response_body_string(resp).contains("\"href\":\"/first-party/click?tsurl="));
+ assert!(response_body_string(resp).contains(&format!(
+ "\"href\":\"{}/first-party/click?tsurl=",
+ settings.publisher.effective_public_origin()
+ )));
});
}
@@ -3684,14 +3810,14 @@ mod tests {
fn reconstruct_valid_with_params_preserves_order() {
let settings = create_test_settings();
let clear = "https://cdn.example/asset.js?c=3&b=2&a=1";
- // Simulate creative-generated first-party URL
- let first_party = creative::build_proxy_url(&settings, clear, "");
- // Reconstruct and validate (need absolute URL for parsing)
- let st = reconstruct_and_validate_signed_target(
+ // Simulate creative-generated first-party URL.
+ let first_party = creative::build_proxy_url(
&settings,
- &format!("https://edge.example{}", first_party),
- )
- .expect("reconstruct ok");
+ clear,
+ &settings.publisher.effective_public_origin(),
+ );
+ let st = reconstruct_and_validate_signed_target(&settings, &first_party)
+ .expect("reconstruct ok");
assert_eq!(st.tsurl, "https://cdn.example/asset.js");
assert!(st.had_params);
assert_eq!(st.target_url, canonical_clear_url(clear));
@@ -3701,12 +3827,13 @@ mod tests {
fn reconstruct_valid_without_params() {
let settings = create_test_settings();
let clear = "https://cdn.example/asset.js";
- let first_party = creative::build_proxy_url(&settings, clear, "");
- let st = reconstruct_and_validate_signed_target(
+ let first_party = creative::build_proxy_url(
&settings,
- &format!("https://edge.example{}", first_party),
- )
- .expect("reconstruct ok");
+ clear,
+ &settings.publisher.effective_public_origin(),
+ );
+ let st = reconstruct_and_validate_signed_target(&settings, &first_party)
+ .expect("reconstruct ok");
assert_eq!(st.tsurl, clear);
assert!(!st.had_params);
assert_eq!(st.target_url, clear);
@@ -3717,10 +3844,13 @@ mod tests {
futures::executor::block_on(async {
let settings = create_test_settings();
let clear = "ftp://cdn.example/file.gif";
- // Build a first-party proxy URL with a token for the unsupported scheme
- let first_party = creative::build_proxy_url(&settings, clear, "");
- let req =
- build_http_request(Method::GET, format!("https://edge.example{}", first_party));
+ // Build a first-party proxy URL with a token for the unsupported scheme.
+ let first_party = creative::build_proxy_url(
+ &settings,
+ clear,
+ &settings.publisher.effective_public_origin(),
+ );
+ let req = build_http_request(Method::GET, first_party);
let err: Report =
handle_first_party_proxy(&settings, &noop_services(), req)
.await
@@ -3757,9 +3887,12 @@ mod tests {
futures::executor::block_on(async {
let settings = create_test_settings();
let clear = "https://cdn.example/landing.html?x=1";
- let first_party = creative::build_click_url(&settings, clear, "");
- let req =
- build_http_request(Method::GET, format!("https://edge.example{}", first_party));
+ let first_party = creative::build_click_url(
+ &settings,
+ clear,
+ &settings.publisher.effective_public_origin(),
+ );
+ let req = build_http_request(Method::GET, first_party);
let resp = handle_first_party_click(&settings, &noop_services(), req)
.await
.expect("should redirect");
diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs
index a670d31416..8d26af7859 100644
--- a/crates/trusted-server-core/src/publisher.rs
+++ b/crates/trusted-server-core/src/publisher.rs
@@ -6362,11 +6362,10 @@ pub(crate) fn build_bid_map_with_auction_id(
) -> serde_json::Map {
// Inline creatives render in a foreign origin (PUC's srcdoc under GAM), so
// their proxy/click URLs must be absolute against the origin the visitor is
- // actually on — scheme, host, and port. Fall back to the configured publisher
- // domain only when the request origin is unknown (e.g. an empty host on a
- // non-navigation path), where no inline render is expected anyway.
+ // actually on — scheme, host, and port. Use the configured public origin
+ // only when the request origin is unknown.
let base_origin = if request_origin.is_empty() {
- format!("https://{}", settings.publisher.domain)
+ settings.publisher.effective_public_origin()
} else {
request_origin.to_owned()
};
@@ -10915,6 +10914,7 @@ mod tests {
})));
let config = HtmlProcessorConfig {
+ public_origin: "https://publisher.example.com".to_string(),
csp_nonce_observed: None,
origin_host: "origin.example.com".to_string(),
request_host: "example.com".to_string(),
@@ -28144,30 +28144,35 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
);
winning_bids.insert("atf_sidebar_ad".to_string(), bid);
- let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false);
- let adm = map
- .get("atf_sidebar_ad")
- .and_then(|v| v.as_object())
- .and_then(|o| o.get("adm"))
- .and_then(|v| v.as_str())
- .expect("should include a rewritten adm");
+ for public_origin in [None, Some("https://ads.example.com:8443")] {
+ settings.publisher.public_origin = public_origin.map(str::to_owned);
+ let map =
+ build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false);
+ let adm = map
+ .get("atf_sidebar_ad")
+ .and_then(|v| v.as_object())
+ .and_then(|o| o.get("adm"))
+ .and_then(|v| v.as_str())
+ .expect("should include a rewritten adm");
+ let expected_origin = public_origin.unwrap_or("https://example.com");
- assert!(
- adm.contains("https://example.com/first-party/proxy?tsurl="),
- "should emit an absolute first-party proxy URL for the foreign-origin render context, got: {adm}"
- );
- assert!(
- !adm.contains("src=\"/first-party/proxy"),
- "should not emit a root-relative proxy URL that 404s under GAM's origin, got: {adm}"
- );
- assert!(
- !adm.contains("https://cdn.example.com/pixel.png"),
- "should proxy the original absolute CDN URL, got: {adm}"
- );
- assert!(
- !adm.contains("/static/tsjs="),
- "should not inject the tsjs bundle into a foreign-origin creative iframe, got: {adm}"
- );
+ assert!(
+ adm.contains(&format!("{expected_origin}/first-party/proxy?tsurl=")),
+ "should use the configured public origin or domain fallback: {adm}"
+ );
+ assert!(
+ !adm.contains("src=\"/first-party/proxy"),
+ "should not emit a root-relative proxy URL under GAM: {adm}"
+ );
+ assert!(
+ !adm.contains("https://cdn.example.com/pixel.png"),
+ "should proxy the original absolute CDN URL: {adm}"
+ );
+ assert!(
+ !adm.contains("/static/tsjs="),
+ "should not inject the tsjs bundle into the inline creative: {adm}"
+ );
+ }
}
#[test]
@@ -28218,10 +28223,11 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
fn build_bid_map_uses_request_origin_for_inline_urls() {
// The inline adm's absolute first-party URLs must resolve against the
// origin the visitor is on (here an HTTP dev host with a port), not the
- // configured publisher domain.
+ // configured public origin or publisher domain.
let mut settings = test_settings();
settings.auction.rewrite_creatives = true;
settings.publisher.domain = "example.com".to_string();
+ settings.publisher.public_origin = Some("https://ads.example.com:8443".to_string());
let mut winning_bids = HashMap::new();
let mut bid = make_bid(
diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs
index 754acfc262..65179c5294 100644
--- a/crates/trusted-server-core/src/settings.rs
+++ b/crates/trusted-server-core/src/settings.rs
@@ -50,6 +50,13 @@ pub struct Publisher {
pub cookie_domain: String,
#[validate(custom(function = validate_no_trailing_slash))]
pub origin_url: String,
+ /// Browser-facing Trusted Server origin for first-party creative URLs.
+ ///
+ /// When omitted, generated URLs fall back to `https://{domain}` for
+ /// compatibility with existing configurations.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[validate(custom(function = validate_public_origin))]
+ pub public_origin: Option,
/// Optional outbound Host header to send while connecting to `origin_url`.
#[serde(default)]
#[validate(custom(function = validate_host_header_override))]
@@ -137,6 +144,7 @@ impl Default for Publisher {
domain: String::default(),
cookie_domain: String::default(),
origin_url: String::default(),
+ public_origin: None,
origin_host_header_override: None,
proxy_secret: Redacted::default(),
max_buffered_body_bytes: default_max_buffered_body_bytes(),
@@ -147,6 +155,19 @@ impl Default for Publisher {
}
impl Publisher {
+ pub(crate) fn normalize(&mut self) -> Result<(), Report> {
+ if let Some(origin) = &mut self.public_origin {
+ let parsed = parse_public_origin(origin).map_err(|error| {
+ let reason = error.message.as_deref().unwrap_or(error.code.as_ref());
+ Report::new(TrustedServerError::Configuration {
+ message: format!("Invalid publisher.public_origin {origin:?}: {reason}"),
+ })
+ })?;
+ *origin = parsed.origin().ascii_serialization();
+ }
+ Ok(())
+ }
+
/// Known placeholder values that must not be used in production.
pub const PROXY_SECRET_PLACEHOLDERS: &[&str] = &[
"change-me-proxy-secret",
@@ -182,6 +203,7 @@ impl Publisher {
/// spelling that resolves to the same host (an explicit `:443`, a trailing
/// slash, a different scheme) cannot slip past the placeholder check.
const PLACEHOLDER_ORIGIN_HOSTS: &[&str] = &["origin.example.com"];
+ const PLACEHOLDER_PUBLIC_ORIGIN_HOSTS: &[&str] = &["ads.example.com"];
/// Returns `true` if `domain` is the unedited template placeholder
/// (case-insensitive).
@@ -219,6 +241,19 @@ impl Publisher {
})
}
+ /// Returns `true` if `public_origin` resolves to the unedited template host.
+ #[must_use]
+ pub fn is_placeholder_public_origin(public_origin: &str) -> bool {
+ Url::parse(public_origin.trim())
+ .ok()
+ .and_then(|url| url.host_str().map(str::to_owned))
+ .is_some_and(|host| {
+ Self::PLACEHOLDER_PUBLIC_ORIGIN_HOSTS
+ .iter()
+ .any(|placeholder| placeholder.eq_ignore_ascii_case(&host))
+ })
+ }
+
/// Extracts the host (including port if present) from the `origin_url`.
///
/// # Examples
@@ -230,6 +265,7 @@ impl Publisher {
/// domain: "example.com".to_string(),
/// cookie_domain: ".example.com".to_string(),
/// origin_url: "https://origin.example.com:8080".to_string(),
+ /// public_origin: None,
/// origin_host_header_override: None,
/// proxy_secret: Redacted::new("proxy-secret".to_string()),
/// max_buffered_body_bytes: 16 * 1024 * 1024,
@@ -252,6 +288,14 @@ impl Publisher {
.unwrap_or_else(|| self.origin_url.clone())
}
+ /// Returns the effective browser-facing origin for generated first-party URLs.
+ #[must_use]
+ pub fn effective_public_origin(&self) -> String {
+ self.public_origin
+ .clone()
+ .unwrap_or_else(|| format!("https://{}", self.domain))
+ }
+
/// Returns the outbound Host header for proxied publisher-origin requests.
#[must_use]
pub fn origin_host_header(&self) -> String {
@@ -3357,8 +3401,10 @@ impl Settings {
///
/// # Errors
///
- /// Returns a configuration error if image optimizer keys collide after trimming.
+ /// Returns a configuration error if the public origin is invalid or image
+ /// optimizer keys collide after trimming.
pub(crate) fn normalize_deserialized(&mut self) -> Result<(), Report> {
+ self.publisher.normalize()?;
self.cache.normalize();
self.proxy.normalize();
self.rewrite.normalize();
@@ -3524,6 +3570,14 @@ impl Settings {
if Publisher::is_placeholder_origin_url(&self.publisher.origin_url) {
insecure_fields.push("publisher.origin_url".to_owned());
}
+ if self
+ .publisher
+ .public_origin
+ .as_deref()
+ .is_some_and(Publisher::is_placeholder_public_origin)
+ {
+ insecure_fields.push("publisher.public_origin".to_owned());
+ }
// Checked whenever the block is present, not just when it is enabled:
// the key rotate/deactivate admin routes are registered unconditionally
// and read these store IDs without consulting `enabled`, so placeholder
@@ -3911,6 +3965,59 @@ fn validate_no_trailing_slash(value: &str) -> Result<(), ValidationError> {
Ok(())
}
+fn validate_public_origin(value: &str) -> Result<(), ValidationError> {
+ parse_public_origin(value).map(|_| ())
+}
+
+fn parse_public_origin(value: &str) -> Result {
+ if value.chars().any(char::is_whitespace) || value.chars().any(char::is_control) {
+ let mut err = ValidationError::new("public_origin_has_whitespace");
+ err.add_param("value".into(), &value);
+ err.message =
+ Some("public_origin must not contain whitespace or control characters".into());
+ return Err(err);
+ }
+
+ if value.ends_with('/') {
+ let mut err = ValidationError::new("public_origin_trailing_slash");
+ err.add_param("value".into(), &value);
+ err.message = Some("public_origin must not include a trailing slash".into());
+ return Err(err);
+ }
+
+ let parsed = Url::parse(value).map_err(|parse_error| {
+ let mut err = ValidationError::new("invalid_public_origin");
+ err.add_param("value".into(), &value);
+ err.add_param("message".into(), &parse_error.to_string());
+ err.message = Some("public_origin must be an absolute http or https origin".into());
+ err
+ })?;
+
+ if !matches!(parsed.scheme(), "http" | "https") {
+ return Err(ValidationError::new("invalid_public_origin_scheme"));
+ }
+ if parsed.host_str().is_none() {
+ return Err(ValidationError::new("missing_public_origin_host"));
+ }
+ if parsed.port() == Some(0) {
+ return Err(ValidationError::new("public_origin_has_invalid_port"));
+ }
+ if !parsed.username().is_empty() || parsed.password().is_some() {
+ return Err(ValidationError::new("public_origin_has_userinfo"));
+ }
+ if !matches!(parsed.path(), "" | "/") {
+ return Err(ValidationError::new("public_origin_has_path"));
+ }
+ if parsed.query().is_some() {
+ return Err(ValidationError::new("public_origin_has_query"));
+ }
+ if parsed.fragment().is_some() {
+ return Err(ValidationError::new("public_origin_has_fragment"));
+ }
+
+ Ok(parsed)
+}
+
fn validate_host_header_override(value: &str) -> Result<(), ValidationError> {
if let Err(reason) = validate_host_header_override_value(value) {
let mut err = ValidationError::new("invalid_host_header_override");
@@ -6125,6 +6232,185 @@ mod tests {
);
}
+ #[test]
+ fn publisher_public_origin_validates_and_falls_back_to_domain() {
+ let fallback = Settings::from_toml(&crate_test_settings_str())
+ .expect("should parse settings without public origin");
+ assert_eq!(
+ fallback.publisher.effective_public_origin(),
+ format!("https://{}", fallback.publisher.domain),
+ "should use HTTPS and the publisher domain when the field is omitted"
+ );
+ let fallback_json = serde_json::to_value(&fallback).expect("should serialize settings");
+ assert!(
+ fallback_json["publisher"].get("public_origin").is_none(),
+ "should omit an unset public origin from serialized configs"
+ );
+
+ for origin in ["https://ads.example.com", "http://localhost:8080"] {
+ let settings = Settings::from_toml(&crate_test_settings_str().replace(
+ "[publisher]",
+ &format!("[publisher]\npublic_origin = \"{origin}\""),
+ ))
+ .expect("should accept an HTTP(S) origin without a trailing slash");
+ assert_eq!(settings.publisher.effective_public_origin(), origin);
+ }
+
+ let with_newline = "https://ads.example.com".replacen('.', "\n", 1);
+ for origin in [
+ "ftp://ads.example.com",
+ "https://user@ads.example.com",
+ "https://ads.example.com/path",
+ "https://ads.example.com:0",
+ "https://ads.example.com?query=1",
+ "https://ads.example.com#fragment",
+ "https://ads.example.com/",
+ " https://ads.example.com",
+ "https://ads.example.com ",
+ with_newline.as_str(),
+ ] {
+ let result = Settings::from_toml(&crate_test_settings_str().replace(
+ "[publisher]",
+ &format!("[publisher]\npublic_origin = \"{origin}\""),
+ ));
+ assert!(
+ result.is_err(),
+ "should reject invalid public origin {origin:?}"
+ );
+ }
+
+ for origin in [" https://ads.example.com", with_newline.as_str()] {
+ let mut json = serde_json::to_value(&fallback).expect("should serialize settings");
+ json["publisher"]["public_origin"] = serde_json::json!(origin);
+ assert!(
+ Settings::from_json_value(json).is_err(),
+ "should reject whitespace/control characters in public origin {origin:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn publisher_public_origin_errors_include_the_reason() {
+ for (origin, reason) in [
+ (
+ "https://ads.example.com/",
+ "public_origin must not include a trailing slash",
+ ),
+ ("https://ads.example.com/path", "public_origin_has_path"),
+ (
+ "https://ads.example.com:0",
+ "public_origin_has_invalid_port",
+ ),
+ ] {
+ let toml = crate_test_settings_str().replace(
+ "[publisher]",
+ &format!("[publisher]\npublic_origin = \"{origin}\""),
+ );
+ let err =
+ Settings::from_toml(&toml).expect_err("should reject the invalid public origin");
+ let message = err.to_string();
+ assert!(
+ message.contains("publisher.public_origin") && message.contains(reason),
+ "should explain the public origin error: {message}"
+ );
+ }
+ }
+
+ #[test]
+ fn is_placeholder_public_origin_matches_the_parsed_template_host() {
+ for origin in [
+ "https://ads.example.com",
+ "http://ADS.EXAMPLE.COM:8443",
+ " https://ads.example.com:443 ",
+ ] {
+ assert!(
+ Publisher::is_placeholder_public_origin(origin),
+ "should recognize the public origin placeholder: {origin}"
+ );
+ }
+ for origin in [
+ "https://creative.example.com",
+ "https://ads.example.com.publisher.example",
+ "not a URL",
+ ] {
+ assert!(
+ !Publisher::is_placeholder_public_origin(origin),
+ "should not classify a different or invalid host as the template: {origin}"
+ );
+ }
+ }
+
+ #[test]
+ fn publisher_public_origin_normalizes_before_generating_urls() {
+ let foreign = Url::parse("https://foreign.example/creative")
+ .expect("should parse the foreign document URL");
+ for (raw, expected) in [
+ ("https:ads.example.com", "https://ads.example.com"),
+ ("https:/ads.example.com", "https://ads.example.com"),
+ ("HTTPS://ADS.EXAMPLE.COM:443", "https://ads.example.com"),
+ ("http://localhost:7676", "http://localhost:7676"),
+ ("http://[::1]:7676", "http://[::1]:7676"),
+ ] {
+ let toml = crate_test_settings_str().replace(
+ "[publisher]",
+ &format!("[publisher]\npublic_origin = \"{raw}\""),
+ );
+ let from_toml = Settings::from_toml(&toml)
+ .expect("should load and normalize a valid public origin");
+ let mut json = serde_json::to_value(&from_toml)
+ .expect("should serialize settings for JSON loading");
+ json["publisher"]["public_origin"] = serde_json::json!(raw);
+ let from_json = Settings::from_json_value(json)
+ .expect("should normalize the public origin from a config blob");
+
+ for settings in [from_toml, from_json] {
+ assert_eq!(
+ settings.publisher.public_origin.as_deref(),
+ Some(expected),
+ "should store the normalized public origin for {raw}"
+ );
+ let generated = crate::creative::build_proxy_url(
+ &settings,
+ "https://cdn.example.com/ad.png",
+ &settings.publisher.effective_public_origin(),
+ );
+ let resolved = foreign
+ .join(&generated)
+ .expect("should resolve generated output in a foreign document");
+ assert_eq!(
+ resolved.origin().ascii_serialization(),
+ expected,
+ "should retain the public authority for {raw}"
+ );
+ let serialized =
+ serde_json::to_value(&settings).expect("should serialize normalized settings");
+ assert_eq!(
+ serialized["publisher"]["public_origin"], expected,
+ "should persist the normalized origin for {raw}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn publisher_public_origin_round_trips_through_json() {
+ let settings = Settings::from_toml(&crate_test_settings_str().replace(
+ "[publisher]",
+ "[publisher]\npublic_origin = \"https://ads.example.com:8443\"",
+ ))
+ .expect("should parse an explicit public origin");
+ let json = serde_json::to_value(&settings).expect("should serialize settings");
+ assert_eq!(
+ json["publisher"]["public_origin"],
+ serde_json::json!("https://ads.example.com:8443")
+ );
+ let reconstructed = Settings::from_json_value(json).expect("should deserialize settings");
+ assert_eq!(
+ reconstructed.publisher.effective_public_origin(),
+ "https://ads.example.com:8443"
+ );
+ }
+
#[test]
fn validate_rejects_invalid_publisher_domains() {
for domain in [
@@ -7065,6 +7351,7 @@ source_domain = "partner.example.com"
domain: "example.com".to_string(),
cookie_domain: ".example.com".to_string(),
origin_url: "https://origin.example.com:8080".to_string(),
+ public_origin: None,
origin_host_header_override: None,
proxy_secret: Redacted::new("test-secret".to_string()),
max_buffered_body_bytes: 16 * 1024 * 1024,
@@ -7078,6 +7365,7 @@ source_domain = "partner.example.com"
domain: "example.com".to_string(),
cookie_domain: ".example.com".to_string(),
origin_url: "https://origin.example.com".to_string(),
+ public_origin: None,
origin_host_header_override: None,
proxy_secret: Redacted::new("test-secret".to_string()),
max_buffered_body_bytes: 16 * 1024 * 1024,
@@ -7091,6 +7379,7 @@ source_domain = "partner.example.com"
domain: "example.com".to_string(),
cookie_domain: ".example.com".to_string(),
origin_url: "http://localhost:9090".to_string(),
+ public_origin: None,
origin_host_header_override: None,
proxy_secret: Redacted::new("test-secret".to_string()),
max_buffered_body_bytes: 16 * 1024 * 1024,
@@ -7104,6 +7393,7 @@ source_domain = "partner.example.com"
domain: "example.com".to_string(),
cookie_domain: ".example.com".to_string(),
origin_url: "localhost:9090".to_string(),
+ public_origin: None,
origin_host_header_override: None,
proxy_secret: Redacted::new("test-secret".to_string()),
max_buffered_body_bytes: 16 * 1024 * 1024,
@@ -7117,6 +7407,7 @@ source_domain = "partner.example.com"
domain: "example.com".to_string(),
cookie_domain: ".example.com".to_string(),
origin_url: "http://192.168.1.1:8080".to_string(),
+ public_origin: None,
origin_host_header_override: None,
proxy_secret: Redacted::new("test-secret".to_string()),
max_buffered_body_bytes: 16 * 1024 * 1024,
@@ -7130,6 +7421,7 @@ source_domain = "partner.example.com"
domain: "example.com".to_string(),
cookie_domain: ".example.com".to_string(),
origin_url: "http://[::1]:8080".to_string(),
+ public_origin: None,
origin_host_header_override: None,
proxy_secret: Redacted::new("test-secret".to_string()),
max_buffered_body_bytes: 16 * 1024 * 1024,
@@ -7145,6 +7437,7 @@ source_domain = "partner.example.com"
domain: "example.com".to_string(),
cookie_domain: ".example.com".to_string(),
origin_url: "https://origin.example.com:8443".to_string(),
+ public_origin: None,
origin_host_header_override: None,
proxy_secret: Redacted::new("test-secret".to_string()),
max_buffered_body_bytes: 16 * 1024 * 1024,
@@ -7161,6 +7454,7 @@ source_domain = "partner.example.com"
domain: "example.com".to_string(),
cookie_domain: ".example.com".to_string(),
origin_url: "https://origin.example.com".to_string(),
+ public_origin: None,
origin_host_header_override: Some("www.example.com".to_string()),
proxy_secret: Redacted::new("test-secret".to_string()),
max_buffered_body_bytes: 16 * 1024 * 1024,
diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs
index e8275b8144..a3deb5cfdc 100644
--- a/crates/trusted-server-core/src/tsjs.rs
+++ b/crates/trusted-server-core/src/tsjs.rs
@@ -20,7 +20,27 @@ pub fn tsjs_script_tag_with_attributes(
module_ids: &[&str],
attributes: &[(&'static str, &'static str)],
) -> String {
- let attributes = attributes
+ script_tag_with_source(&tsjs_script_src(module_ids), attributes, None)
+}
+
+/// Runtime tag with the configured origin used to recognize existing proxy URLs.
+///
+/// Dynamic source and origin values are escaped independently of trusted static attributes.
+#[must_use]
+pub(crate) fn tsjs_script_tag_with_public_origin(
+ src: &str,
+ attributes: &[(&'static str, &'static str)],
+ public_origin: &str,
+) -> String {
+ script_tag_with_source(src, attributes, Some(public_origin))
+}
+
+fn script_tag_with_source(
+ src: &str,
+ attributes: &[(&'static str, &'static str)],
+ public_origin: Option<&str>,
+) -> String {
+ let mut attributes = attributes
.iter()
.map(|(name, value)| {
debug_assert!(
@@ -40,12 +60,27 @@ pub fn tsjs_script_tag_with_attributes(
})
.collect::();
+ if let Some(public_origin) = public_origin {
+ attributes.push_str(&format!(
+ " data-ts-public-origin=\"{}\"",
+ escape_attribute_value(public_origin)
+ ));
+ }
+
format!(
"",
- tsjs_script_src(module_ids),
+ escape_attribute_value(src),
)
}
+fn escape_attribute_value(value: &str) -> String {
+ value
+ .replace('&', "&")
+ .replace('"', """)
+ .replace('<', "<")
+ .replace('>', ">")
+}
+
/// `/static` URL for the unified bundle when exact module IDs are unavailable.
///
/// This intentionally omits `?v=` because the serving path can only mark a URL
@@ -59,17 +94,6 @@ pub fn tsjs_unified_script_src() -> String {
"/static/tsjs=tsjs-unified.min.js".to_string()
}
-/// `",
- tsjs_unified_script_src()
- )
-}
-
/// `/static` URL for one module with its own cache-busting hash.
#[must_use]
pub fn tsjs_single_module_script_src(module_id: &str) -> String {
@@ -221,6 +245,46 @@ mod tests {
);
}
+ #[test]
+ fn runtime_metadata_preserves_source_hash_and_static_attributes() {
+ let src = tsjs_script_src(&["gpt", "creative"]);
+ let origin = String::from("https://ads.publisher.example:8443");
+ assert_eq!(
+ tsjs_script_tag_with_public_origin(
+ &src,
+ &[("data-ts-gam-attribution", "true")],
+ &origin,
+ ),
+ format!(
+ ""
+ ),
+ "should add metadata without changing the source or static attributes"
+ );
+ }
+
+ #[test]
+ fn runtime_metadata_escapes_dynamic_values() {
+ assert_eq!(
+ tsjs_script_tag_with_public_origin(
+ "/static/test?v=a&b=\"\"",
+ &[],
+ "https://example.com/\"<&>"
+ ),
+ "",
+ "should escape dynamic source and origin without introducing attributes or markup"
+ );
+ }
+
+ #[test]
+ #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")]
+ fn runtime_metadata_keeps_static_attribute_contract() {
+ let _ = tsjs_script_tag_with_public_origin(
+ "/static/test",
+ &[("data-static", "bad&value")],
+ "https://example.com",
+ );
+ }
+
#[test]
#[should_panic(
expected = "attribute name should contain only lowercase ASCII letters, digits, and hyphens"
@@ -269,11 +333,6 @@ mod tests {
src, "/static/tsjs=tsjs-unified.min.js",
"registry-free unified helper should not emit an unverifiable hash"
);
- assert_eq!(
- tsjs_unified_script_tag(),
- format!(r#""#),
- "should wrap the registry-free unified source"
- );
}
#[test]
@@ -336,18 +395,13 @@ mod tests {
}
#[test]
- fn tsjs_unified_script_src_and_tag_omit_unverifiable_cache_busting_hash() {
+ fn tsjs_unified_script_src_omits_unverifiable_cache_busting_hash() {
let src = tsjs_unified_script_src();
assert_eq!(
src, "/static/tsjs=tsjs-unified.min.js",
"should use the unified script URL without an unverifiable hash"
);
- assert_eq!(
- tsjs_unified_script_tag(),
- format!(r#""#),
- "should wrap the unified source in a trusted server script tag"
- );
}
#[test]
diff --git a/crates/trusted-server-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts
index 0402bb266e..cd83913da5 100644
--- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts
+++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts
@@ -1,13 +1,19 @@
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
import { setTimeout as setTimeoutPromise } from "node:timers/promises";
import { createServer } from "node:net";
+import { randomUUID } from "node:crypto";
+import { rmSync } from "node:fs";
+import { resolve } from "node:path";
import { waitForReady } from "./wait-for-ready.js";
const ORIGIN_PORT = process.env.INTEGRATION_ORIGIN_PORT || "8888";
/** Framework-specific container configuration. */
const FRAMEWORK_CONFIG: Record = {
- nextjs: { image: "test-nextjs:latest", port: 3000 },
+ nextjs: {
+ image: process.env.NEXTJS_TEST_IMAGE || "test-nextjs:latest",
+ port: 3000,
+ },
wordpress: { image: "test-wordpress:latest", port: 80 },
};
@@ -90,38 +96,70 @@ export function stopContainer(containerId: string): void {
export async function startViceroy(
wasmPath: string,
configPath: string,
+ options: { publicOriginHostname?: string } = {},
): Promise<{ process: ChildProcess; baseUrl: string }> {
const port = await findAvailablePort();
-
- const child = spawn(
- "viceroy",
- [wasmPath, "-C", configPath, "--addr", `127.0.0.1:${port}`],
- // stdin: ignore, stdout: pipe (drained), stderr: pipe (logged).
- // IMPORTANT: stdout MUST be drained to prevent pipe buffer deadlock.
- // If the OS pipe buffer (~64KB) fills, Viceroy blocks on write.
- { stdio: ["ignore", "pipe", "pipe"] },
- );
-
- // Drain stdout to prevent pipe buffer deadlock
- child.stdout?.resume();
-
- // Surface Viceroy stderr for debugging
- child.stderr?.on("data", (data: Buffer) => {
- const line = data.toString().trim();
- if (line) console.error(`[viceroy] ${line}`);
- });
-
const baseUrl = `http://127.0.0.1:${port}`;
+ const publicOrigin = new URL(baseUrl);
+ if (options.publicOriginHostname) {
+ publicOrigin.hostname = options.publicOriginHostname;
+ }
+ let generator = process.env.VICEROY_CONFIG_GENERATOR_PATH;
+ if (!generator) {
+ const host = execFileSync("rustc", ["-vV"], { encoding: "utf-8" }).match(
+ /^host: (.+)$/m,
+ )?.[1];
+ if (!host) throw new Error("Failed to detect Rust host target");
+ generator = resolve(
+ __dirname,
+ "../../target",
+ host,
+ "debug/generate-viceroy-config",
+ );
+ }
+ // Retarget the existing envelope after choosing the listener port. Keep the
+ // config beside its source so relative Viceroy resource paths still work.
+ const runtimeConfig = `${configPath}.${randomUUID()}.toml`;
try {
- await waitForReady(baseUrl, "/health");
- } catch (err) {
- // Viceroy spawned but never became ready — kill it before propagating.
- if (child.pid) await stopViceroy(child.pid);
- throw err;
- }
+ execFileSync(generator, [
+ "--template",
+ configPath,
+ "--output",
+ runtimeConfig,
+ "--public-origin",
+ publicOrigin.origin,
+ ]);
+ const child = spawn(
+ "viceroy",
+ [wasmPath, "-C", runtimeConfig, "--addr", `127.0.0.1:${port}`],
+ // stdin: ignore, stdout: pipe (drained), stderr: pipe (logged).
+ // IMPORTANT: stdout MUST be drained to prevent pipe buffer deadlock.
+ // If the OS pipe buffer (~64KB) fills, Viceroy blocks on write.
+ { stdio: ["ignore", "pipe", "pipe"] },
+ );
+
+ // Drain stdout to prevent pipe buffer deadlock
+ child.stdout?.resume();
- return { process: child, baseUrl };
+ // Surface Viceroy stderr for debugging
+ child.stderr?.on("data", (data: Buffer) => {
+ const line = data.toString().trim();
+ if (line) console.error(`[viceroy] ${line}`);
+ });
+
+ try {
+ await waitForReady(baseUrl, "/health");
+ } catch (err) {
+ // Viceroy spawned but never became ready, kill it before propagating.
+ if (child.pid) await stopViceroy(child.pid);
+ throw err;
+ }
+
+ return { process: child, baseUrl };
+ } finally {
+ rmSync(runtimeConfig, { force: true });
+ }
}
/**
diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/configured-proxy-origin.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/configured-proxy-origin.spec.ts
new file mode 100644
index 0000000000..43e97d9181
--- /dev/null
+++ b/crates/trusted-server-integration-tests/browser/tests/shared/configured-proxy-origin.spec.ts
@@ -0,0 +1,271 @@
+import { test, expect } from "@playwright/test";
+import { createServer, type Server } from "node:http";
+import { resolve } from "node:path";
+import { startViceroy, stopViceroy } from "../../helpers/infra.js";
+
+let runtime: Awaited>;
+let assets: Server;
+let assetOrigin: string;
+
+// Use real loopback origins, with normal browser security and actual proxy token
+// verification. The publisher bundle stays on 127.0.0.1 while configured proxy
+// URLs use localhost on the same listener. No fulfilled browser routes or CORS
+// exceptions stand in for a reachable server.
+test.beforeAll(async () => {
+ assets = createServer((request, response) => {
+ if (request.url?.startsWith("/pixel.gif")) {
+ response.writeHead(200, { "content-type": "image/gif" });
+ response.end(
+ Buffer.from(
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
+ "base64",
+ ),
+ );
+ } else {
+ response.writeHead(200, { "content-type": "text/html" });
+ response.end(
+ "Proxy framesigned frame",
+ );
+ }
+ });
+ await new Promise((resolve, reject) => {
+ assets.once("error", reject);
+ assets.listen(0, "127.0.0.1", resolve);
+ });
+ const address = assets.address();
+ if (!address || typeof address === "string")
+ throw new Error("Missing asset listener");
+ assetOrigin = `http://127.0.0.1:${address.port}`;
+ runtime = await startViceroy(
+ process.env.WASM_BINARY_PATH ||
+ resolve(
+ __dirname,
+ "../../../../../target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm",
+ ),
+ process.env.VICEROY_CONFIG_PATH ||
+ resolve(
+ __dirname,
+ "../../../../../target/integration-test-artifacts/configs/viceroy.toml",
+ ),
+ { publicOriginHostname: "localhost" },
+ );
+});
+
+test.afterAll(async () => {
+ if (runtime?.process.pid) await stopViceroy(runtime.process.pid);
+ if (assets)
+ await new Promise((resolve, reject) =>
+ assets.close((error) => (error ? reject(error) : resolve())),
+ );
+});
+
+test("does not double-proxy images or iframes on a distinct configured origin", async ({
+ page,
+}, testInfo) => {
+ const publicOrigin = new URL(runtime.baseUrl);
+ publicOrigin.hostname = "localhost";
+ const signed: Record = {};
+ for (const phase of ["initial", "mutation"]) {
+ for (const [kind, path] of [
+ ["image", "/pixel.gif"],
+ ["iframe", "/frame.html"],
+ ]) {
+ const target = `${assetOrigin}${path}?phase=${phase}`;
+ const response = await page.request.post(
+ `${runtime.baseUrl}/first-party/sign`,
+ { data: { url: target } },
+ );
+ expect(response.ok()).toBe(true);
+ const href = ((await response.json()) as { href: string }).href;
+ const url = new URL(href);
+ expect(url.origin).toBe(publicOrigin.origin);
+ expect(url.pathname).toBe("/first-party/proxy");
+ const targetBase = new URL(target);
+ targetBase.search = "";
+ expect(url.searchParams.get("tsurl")).toBe(targetBase.href);
+ expect(url.searchParams.get("phase")).toBe(phase);
+ expect(url.searchParams.get("tstoken")).toBeTruthy();
+ signed[`${phase}-${kind}`] = href;
+ }
+ }
+ // A negative control proves the real proxy validates tokens, rather than a
+ // fixture accepting arbitrary requests at a matching pathname.
+ const invalid = new URL(signed["initial-image"]);
+ invalid.searchParams.set("tstoken", "invalid-token");
+ const invalidTokenStatus = (await page.request.get(invalid.href)).status();
+ expect(invalidTokenStatus).toBe(403);
+
+ const requests: string[] = [];
+ const responses: { url: string; status: number; resourceType: string }[] = [];
+ page.on("request", (request) => requests.push(request.url()));
+ page.on("response", (response) =>
+ responses.push({
+ url: response.url(),
+ status: response.status(),
+ resourceType: response.request().resourceType(),
+ }),
+ );
+ await page.goto(runtime.baseUrl, { waitUntil: "load" });
+ const metadata = await page
+ .locator('script[src*="/static/tsjs="]')
+ .first()
+ .evaluate((script) => ({
+ src: script.getAttribute("src"),
+ origin: new URL((script as HTMLScriptElement).src).origin,
+ configured: script.getAttribute("data-ts-public-origin"),
+ }));
+ expect(metadata.src).toMatch(/^\/static\/tsjs=/);
+ expect(metadata.origin).toBe(runtime.baseUrl);
+ expect(metadata.configured).toBe(publicOrigin.origin);
+ await page.evaluate(() => {
+ // Keep the server-injected runtime, but remove framework fixture resources
+ // before enabling the guard so only this test's images and frames are signed.
+ document.body.replaceChildren();
+ (
+ window as unknown as {
+ tscreative: { setConfig(config: { renderGuard: boolean }): void };
+ }
+ ).tscreative.setConfig({ renderGuard: true });
+ });
+
+ // Prove renderGuard is active before testing already-proxied resources. A
+ // disabled guard would otherwise pass the zero-sign assertions vacuously.
+ const rawControl = `${assetOrigin}/pixel.gif?phase=control`;
+ const controlSignResponse = page.waitForResponse(
+ (response) =>
+ new URL(response.url()).pathname === "/first-party/sign" &&
+ response.request().method() === "POST",
+ );
+ await page.evaluate((src) => {
+ const image = new Image();
+ image.id = "active-guard-control";
+ image.src = src;
+ document.body.appendChild(image);
+ }, rawControl);
+ const signResponse = await controlSignResponse;
+ expect(signResponse.ok()).toBe(true);
+ expect(new URL(signResponse.url()).origin).toBe(runtime.baseUrl);
+ expect(signResponse.request().postDataJSON()).toEqual({ url: rawControl });
+ const controlHref = ((await signResponse.json()) as { href: string }).href;
+ expect(new URL(controlHref).origin).toBe(publicOrigin.origin);
+ expect(new URL(controlHref).pathname).toBe("/first-party/proxy");
+ await expect
+ .poll(() =>
+ responses.some(
+ (response) =>
+ response.url === controlHref &&
+ response.status === 200 &&
+ response.resourceType === "image",
+ ),
+ )
+ .toBe(true);
+ await expect(page.locator("#active-guard-control")).toHaveAttribute(
+ "src",
+ controlHref,
+ );
+ await expect
+ .poll(() =>
+ page
+ .locator("#active-guard-control")
+ .evaluate((image) => (image as HTMLImageElement).naturalWidth),
+ )
+ .toBe(1);
+ expect(
+ requests.filter((url) => new URL(url).pathname === "/first-party/sign"),
+ ).toEqual([`${runtime.baseUrl}/first-party/sign`]);
+ const control = {
+ raw: rawControl,
+ signed: controlHref,
+ requests: [...requests],
+ responses: [...responses],
+ };
+ await page
+ .locator("#active-guard-control")
+ .evaluate((image) => image.remove());
+ requests.length = 0;
+ responses.length = 0;
+
+ for (const phase of ["initial", "mutation"]) {
+ const expected = {
+ image: signed[`${phase}-image`],
+ iframe: signed[`${phase}-iframe`],
+ };
+ await page.evaluate(
+ ({ phase, expected }) => {
+ if (phase === "initial") {
+ const image = new Image();
+ image.id = "configured-proxy-image";
+ image.src = expected.image;
+ document.body.appendChild(image);
+ // innerHTML exercises MutationObserver processing, not just src setters.
+ const container = document.createElement("div");
+ container.innerHTML = ``;
+ document.body.appendChild(container);
+ } else {
+ document
+ .querySelector("#configured-proxy-image")!
+ .setAttribute("src", expected.image);
+ document
+ .querySelector("#configured-proxy-iframe")!
+ .setAttribute("src", expected.iframe);
+ }
+ },
+ { phase, expected },
+ );
+ for (const [kind, href] of Object.entries(expected)) {
+ await expect
+ .poll(() =>
+ responses.some(
+ (response) =>
+ response.url === href &&
+ response.status === 200 &&
+ response.resourceType ===
+ (kind === "image" ? "image" : "document"),
+ ),
+ )
+ .toBe(true);
+ await expect(page.locator(`#configured-proxy-${kind}`)).toHaveAttribute(
+ "src",
+ href,
+ );
+ }
+ await expect
+ .poll(() =>
+ page
+ .locator("#configured-proxy-image")
+ .evaluate((image) => (image as HTMLImageElement).naturalWidth),
+ )
+ .toBe(1);
+ const frameScript = page
+ .frameLocator("#configured-proxy-iframe")
+ .locator('script[src*="/static/tsjs="]')
+ .first();
+ await expect(frameScript).toHaveAttribute(
+ "data-ts-public-origin",
+ publicOrigin.origin,
+ );
+ expect(new URL((await frameScript.getAttribute("src"))!).origin).toBe(
+ publicOrigin.origin,
+ );
+ }
+ expect(
+ requests.filter((url) => new URL(url).pathname === "/first-party/sign"),
+ ).toEqual([]);
+ const proxyRequests = requests.filter(
+ (url) => new URL(url).pathname === "/first-party/proxy",
+ );
+ expect(new Set(proxyRequests)).toEqual(new Set(Object.values(signed)));
+ for (const href of proxyRequests) {
+ const target = new URL(new URL(href).searchParams.get("tsurl")!);
+ expect(target.origin).toBe(assetOrigin);
+ expect(target.pathname).not.toBe("/first-party/proxy");
+ }
+ await testInfo.attach("configured-proxy-network", {
+ body: JSON.stringify(
+ { metadata, invalidTokenStatus, control, signed, requests, responses },
+ null,
+ 2,
+ ),
+ contentType: "application/json",
+ });
+});
diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts
index 580e7db3e4..47f44c4f71 100644
--- a/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts
+++ b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts
@@ -3,10 +3,10 @@ import { runtimeUrl } from "../../helpers/state.js";
// Creative iframes are sandboxed WITHOUT `allow-same-origin`, so the creative
// runtime executes in an opaque origin whose `location.href` is `about:srcdoc`.
-// jsdom cannot reproduce either condition, so the recovery path these tests
-// cover — resolve against the stamped first-party origin, skip the CORS-doomed
-// POST, navigate the GET rebuild fallback, follow the re-signed click — is only
-// observable in a real browser.
+// jsdom cannot reproduce either condition. In a real browser, clicks skip the
+// CORS-doomed POST and navigate a GET rebuild URL. Without pre-capture spoofing,
+// that URL uses the absolute runtime script's public origin and follows the
+// signed rebuild/click chain. Earlier script can instead spoof that origin.
const CREATIVE_SANDBOX_TOKENS = [
"allow-forms",
"allow-popups",
@@ -15,121 +15,188 @@ const CREATIVE_SANDBOX_TOKENS = [
"allow-top-navigation-by-user-activation",
].join(" ");
-// A creative script that tries to redirect click resolution to an
-// attacker origin. It executes before the runtime injected at the top of
-// , so the stamp must be non-writable for the recovery below to stay on
-// the first-party origin.
+// The stamp stays immutable, but currentScript is not authenticated. A creative
+// script running before runtime capture can redirect endpoints by shadowing it.
+// The same attack after capture cannot change the pinned endpoint origin.
+const ATTACKER_ORIGIN = "https://attacker.invalid";
const HOSTILE_STAMP_OVERWRITE = ``;
test.describe("Sandboxed creative iframe", () => {
- test("recovers a mutated click through the signed GET rebuild chain", async ({
- page,
- }) => {
- const origin = new URL(runtimeUrl("/")).origin;
-
- // Mint a genuinely signed click without knowing the proxy secret: the
- // signing endpoint computes its token over `tsurl` plus params, which is
- // exactly what the click endpoint validates, so the same query is valid
- // under either path.
- const landing = runtimeUrl("/health");
- const signResponse = await page.request.post(
- runtimeUrl("/first-party/sign"),
- { data: { url: landing } },
- );
- expect(
- signResponse.ok(),
- "signing endpoint should mint a token for the fixture URL",
- ).toBe(true);
- const signedProxyHref = ((await signResponse.json()) as { href: string })
- .href;
- const signedClick = signedProxyHref.replace(
- "/first-party/proxy?",
- "/first-party/click?",
- );
- expect(signedClick.startsWith("/first-party/click?")).toBe(true);
-
- await page.goto(runtimeUrl("/"), { waitUntil: "domcontentloaded" });
-
- // Reuse whichever hashed bundle URL the server injected into the page so
- // this test never has to know the current content hash; fall back to the
- // stable unified path if the fixture page carries no injected script.
- const injectedBundle = await page.evaluate(() => {
- const script = Array.from(document.querySelectorAll("script[src]")).find(
- (element) =>
+ for (const [parent, attack] of [
+ ["first-party", "before capture"],
+ ["first-party", "after capture"],
+ ["foreign", "before capture"],
+ ["foreign", "after capture"],
+ ] as const) {
+ test(`currentScript shadowing ${attack} with a ${parent} parent ${attack === "before capture" ? "retargets endpoints despite the stamp" : "cannot move the signed GET rebuild chain"}`, async ({
+ page,
+ }) => {
+ const origin = new URL(runtimeUrl("/")).origin;
+
+ // Mint a genuinely signed click without knowing the proxy secret: the
+ // signing endpoint computes its token over `tsurl` plus params, which is
+ // exactly what the click endpoint validates, so the same query is valid
+ // under either path.
+ const landing = runtimeUrl("/health");
+ const signResponse = await page.request.post(
+ runtimeUrl("/first-party/sign"),
+ { data: { url: landing } },
+ );
+ expect(
+ signResponse.ok(),
+ "signing endpoint should mint a token for the fixture URL",
+ ).toBe(true);
+ const signedProxyHref = ((await signResponse.json()) as { href: string })
+ .href;
+ const signedUrl = new URL(signedProxyHref);
+ expect(signedUrl.origin).toBe(origin);
+ expect(signedUrl.pathname).toBe("/first-party/proxy");
+ signedUrl.pathname = "/first-party/click";
+ const signedClick = signedUrl.href;
+
+ await page.goto(runtimeUrl("/"), { waitUntil: "domcontentloaded" });
+
+ // Reuse whichever hashed bundle URL the server injected into the page so
+ // this test never has to know the current content hash; fall back to the
+ // stable unified path if the fixture page carries no injected script.
+ const injectedBundle = await page.evaluate(() => {
+ const script = Array.from(
+ document.querySelectorAll("script[src]"),
+ ).find((element) =>
(element as HTMLScriptElement).src.includes("/static/tsjs="),
+ );
+ return script ? (script as HTMLScriptElement).src : null;
+ });
+ const bundleUrl =
+ injectedBundle ?? runtimeUrl("/static/tsjs=tsjs-unified.min.js");
+
+ expect(new URL(bundleUrl).origin).toBe(origin);
+
+ let parentOrigin = origin;
+ if (parent === "foreign") {
+ const parentUrl = new URL("/", origin);
+ parentUrl.hostname = "localhost";
+ parentOrigin = parentUrl.origin;
+ expect(parentOrigin).not.toBe(origin);
+ // Real navigation preserves loopback address-space metadata. A
+ // synthetic fulfilled parent blocks the opaque frame's runtime load.
+ await page.goto(parentUrl.href, { waitUntil: "domcontentloaded" });
+ await page.setContent(
+ "Foreign parent",
+ );
+ }
+ expect(await page.evaluate(() => location.origin)).toBe(parentOrigin);
+
+ // Keep spoofed navigation local to the test. We assert its destination,
+ // not a response from a real attacker or a successful signed click chain.
+ await page.route(`${ATTACKER_ORIGIN}/**`, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "text/html",
+ body: "spoofed endpoint",
+ }),
);
- return script ? (script as HTMLScriptElement).src : null;
- });
- const bundleUrl =
- injectedBundle ?? runtimeUrl("/static/tsjs=tsjs-unified.min.js");
-
- // Mirrors the srcdoc document the client builds: the first-party parent
- // stamps its own origin ahead of any creative markup, then the runtime,
- // then the creative — here preceded by hostile markup attempting to move
- // the stamp.
- const creativeDocument = `
+
+ // A foreign parent's stamp does not override the runtime script origin.
+ // Nor does the stamp authenticate currentScript against earlier markup.
+ const creativeDocument = `
+ ${attack === "before capture" ? HOSTILE_STAMP_OVERWRITE : ""}
- ${HOSTILE_STAMP_OVERWRITE}
+ ${attack === "after capture" ? HOSTILE_STAMP_OVERWRITE : ""}
ad
`;
- const rebuildResponse = page.waitForResponse(
- (response) => response.url().includes("/first-party/proxy-rebuild"),
- { timeout: 15_000 },
- );
- const clickResponse = page.waitForResponse(
- (response) =>
- response.url().includes("/first-party/click") &&
- response.request().resourceType() === "document",
- { timeout: 15_000 },
- );
-
- await page.evaluate(
- ({ sandbox, html }) => {
- const iframe = document.createElement("iframe");
- iframe.setAttribute("sandbox", sandbox);
- iframe.srcdoc = html;
- iframe.style.width = "300px";
- iframe.style.height = "250px";
- document.body.appendChild(iframe);
- },
- { sandbox: CREATIVE_SANDBOX_TOKENS, html: creativeDocument },
- );
-
- const frame = page.frameLocator("iframe");
- const link = frame.locator("#creative-link");
- await link.waitFor({ state: "attached", timeout: 10_000 });
-
- // The creative mutates its own click target, the shape the click guard
- // exists to repair.
- await link.evaluate((element, click: string) => {
- element.setAttribute("href", `${click}&bar=2`);
- }, signedClick);
-
- await link.click({ force: true });
-
- const rebuild = await rebuildResponse;
- // The hostile overwrite must not have moved resolution off the first-party
- // origin, and the rebuild must actually succeed rather than error.
- expect(rebuild.url().startsWith(origin)).toBe(true);
- expect(rebuild.status(), "GET rebuild should redirect").toBe(302);
-
- const click = await clickResponse;
- expect(click.url().startsWith(origin)).toBe(true);
- expect(click.status(), "re-signed click should redirect").toBe(302);
- // The rebuilt click carries the mutation the creative added.
- expect(decodeURIComponent(click.url())).toContain("bar=2");
- });
+ const rebuildResponse = page.waitForResponse(
+ (response) => response.url().includes("/first-party/proxy-rebuild"),
+ { timeout: 15_000 },
+ );
+ const clickResponse =
+ attack === "after capture"
+ ? page.waitForResponse(
+ (response) =>
+ response.url().includes("/first-party/click") &&
+ response.request().resourceType() === "document",
+ { timeout: 15_000 },
+ )
+ : null;
+
+ await page.evaluate(
+ ({ sandbox, html }) => {
+ const iframe = document.createElement("iframe");
+ iframe.setAttribute("sandbox", sandbox);
+ iframe.srcdoc = html;
+ iframe.style.width = "300px";
+ iframe.style.height = "250px";
+ document.body.appendChild(iframe);
+ },
+ { sandbox: CREATIVE_SANDBOX_TOKENS, html: creativeDocument },
+ );
+
+ const frame = page.frameLocator("iframe");
+ const link = frame.locator("#creative-link");
+ await link.waitFor({ state: "attached", timeout: 10_000 });
+ expect(
+ await link.evaluate(() => ({
+ stamp: (window as { __tsCreativeOrigin?: string }).__tsCreativeOrigin,
+ script: document.currentScript?.getAttribute("src"),
+ origin: window.origin,
+ href: location.href,
+ })),
+ ).toEqual({
+ stamp: parentOrigin,
+ script: `${ATTACKER_ORIGIN}/static/tsjs=x.js`,
+ origin: "null",
+ href: "about:srcdoc",
+ });
+
+ // The creative mutates its own click target, the shape the click guard
+ // exists to repair.
+ await link.evaluate((element, click: string) => {
+ element.setAttribute("href", `${click}&bar=2`);
+ }, signedClick);
+
+ await link.click({ force: true });
+
+ const rebuild = await rebuildResponse;
+ expect(rebuild.request().method()).toBe("GET");
+ expect(rebuild.request().resourceType()).toBe("document");
+ const rebuildUrl = new URL(rebuild.url());
+ expect(rebuildUrl.searchParams.get("tsclick")).toBe(signedClick);
+ expect(JSON.parse(rebuildUrl.searchParams.get("add") ?? "{}")).toEqual({
+ bar: "2",
+ });
+ if (attack === "before capture") {
+ // The immutable stamp survives, but the spoofed script wins at capture.
+ expect(rebuildUrl.origin).toBe(ATTACKER_ORIGIN);
+ expect(rebuild.status()).toBe(200);
+ return;
+ }
+ // Only changes after capture are prevented from moving the endpoint.
+ expect(rebuildUrl.origin).toBe(origin);
+ expect(rebuild.status(), "GET rebuild should redirect").toBe(302);
+
+ const click = await clickResponse!;
+ expect(new URL(click.url()).origin).toBe(origin);
+ expect(click.status(), "re-signed click should redirect").toBe(302);
+ // The rebuilt click carries the mutation the creative added.
+ expect(decodeURIComponent(click.url())).toContain("bar=2");
+ });
+ }
});
diff --git a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs
index 741c4237cd..8bd86d68aa 100644
--- a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs
+++ b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs
@@ -19,9 +19,10 @@ type DynError = Box;
#[derive(Debug, PartialEq)]
struct Args {
template: PathBuf,
- app_config: PathBuf,
+ app_config: Option,
output: PathBuf,
origin_url: Option,
+ public_origin: Option,
bidder_origin_url: Option,
}
@@ -36,19 +37,31 @@ fn run(args: &Args) -> Result<(), DynError> {
args.template.display()
))
})?;
- let app_config = fs::read_to_string(&args.app_config).map_err(|error| {
- error_box(format!(
- "failed to read Trusted Server app config `{}`: {error}",
- args.app_config.display()
- ))
- })?;
-
- let envelope_json = build_app_config_envelope(&app_config, args.origin_url.as_deref())?;
- let mut generated_config = inject_generated_config_stores(&template, &envelope_json)?;
- if let Some(origin) = &args.bidder_origin_url {
- generated_config =
- inject_controlled_bidder_backends(&generated_config, &app_config, origin)?;
- }
+ let generated_config = if let Some(app_config_path) = &args.app_config {
+ let app_config = fs::read_to_string(app_config_path).map_err(|error| {
+ error_box(format!(
+ "failed to read Trusted Server app config `{}`: {error}",
+ app_config_path.display()
+ ))
+ })?;
+ let envelope_json = build_app_config_envelope(
+ &app_config,
+ args.origin_url.as_deref(),
+ args.public_origin.as_deref(),
+ )?;
+ let generated_config = inject_generated_config_stores(&template, &envelope_json)?;
+ if let Some(origin) = &args.bidder_origin_url {
+ inject_controlled_bidder_backends(&generated_config, &app_config, origin)?
+ } else {
+ generated_config
+ }
+ } else {
+ retarget_generated_config(
+ &template,
+ args.origin_url.as_deref(),
+ args.public_origin.as_deref(),
+ )?
+ };
if let Some(parent) = args.output.parent() {
fs::create_dir_all(parent).map_err(|error| {
@@ -73,6 +86,7 @@ fn parse_args(args: impl IntoIterator- ) -> Result
let mut app_config = None;
let mut output = None;
let mut origin_url = None;
+ let mut public_origin = None;
let mut bidder_origin_url = None;
let mut iter = args.into_iter();
@@ -80,6 +94,9 @@ fn parse_args(args: impl IntoIterator
- ) -> Result
match arg.as_str() {
"--template" => template = Some(next_path_arg(&mut iter, "--template")?),
"--app-config" => app_config = Some(next_path_arg(&mut iter, "--app-config")?),
+ "--public-origin" => {
+ public_origin = Some(next_string_arg(&mut iter, "--public-origin")?)
+ }
"--output" => output = Some(next_path_arg(&mut iter, "--output")?),
"--origin-url" => origin_url = Some(next_string_arg(&mut iter, "--origin-url")?),
"--bidder-origin-url" => {
@@ -95,13 +112,17 @@ fn parse_args(args: impl IntoIterator
- ) -> Result
}
}
+ if bidder_origin_url.is_some() && app_config.is_none() {
+ return Err(error_box("--bidder-origin-url requires --app-config"));
+ }
+
Ok(Args {
template: template
.ok_or_else(|| error_box(format!("missing --template\n\n{}", usage())))?,
- app_config: app_config
- .ok_or_else(|| error_box(format!("missing --app-config\n\n{}", usage())))?,
+ app_config,
output: output.ok_or_else(|| error_box(format!("missing --output\n\n{}", usage())))?,
origin_url,
+ public_origin,
bidder_origin_url,
})
}
@@ -211,19 +232,31 @@ fn next_string_arg(
}
fn usage() -> String {
- "usage: generate-viceroy-config --template --app-config --output [--origin-url ] [--bidder-origin-url ]".to_string()
+ "usage: generate-viceroy-config --template [--app-config ] --output [--origin-url ] [--public-origin ] [--bidder-origin-url (requires --app-config)]\nWithout --app-config, retarget the blob in an already generated Viceroy config.".to_string()
}
fn build_app_config_envelope(
app_config_toml: &str,
origin_url: Option<&str>,
+ public_origin: Option<&str>,
) -> Result {
let app_config: TrustedServerAppConfig = toml::from_str(app_config_toml)
.map_err(|error| error_box(format!("invalid Trusted Server app config: {error}")))?;
+ serialize_app_config_envelope(app_config, origin_url, public_origin)
+}
+
+fn serialize_app_config_envelope(
+ app_config: TrustedServerAppConfig,
+ origin_url: Option<&str>,
+ public_origin: Option<&str>,
+) -> Result {
let mut settings = app_config.into_settings();
if let Some(origin_url) = origin_url {
settings.publisher.origin_url = origin_url.to_string();
}
+ if let Some(public_origin) = public_origin {
+ settings.publisher.public_origin = Some(public_origin.to_string());
+ }
let app_config = TrustedServerAppConfig::new(settings)
.map_err(|report| error_box(format!("invalid Trusted Server app config: {report:?}")))?;
@@ -237,6 +270,31 @@ fn build_app_config_envelope(
.map_err(|error| error_box(format!("failed to serialize app-config envelope: {error}")))
}
+fn retarget_generated_config(
+ config: &str,
+ origin_url: Option<&str>,
+ public_origin: Option<&str>,
+) -> Result {
+ let mut config: toml::Value = toml::from_str(config)?;
+ let contents = config
+ .get_mut("local_server")
+ .and_then(|value| value.get_mut("config_stores"))
+ .and_then(|value| value.get_mut(DEFAULT_CONFIG_STORE_ID))
+ .and_then(|value| value.get_mut("contents"))
+ .and_then(toml::Value::as_table_mut)
+ .ok_or_else(|| error_box("generated Viceroy config should contain an app-config store"))?;
+ let envelope_json = contents
+ .get(CONFIG_BLOB_KEY)
+ .and_then(toml::Value::as_str)
+ .ok_or_else(|| error_box("generated Viceroy config should contain an app-config blob"))?;
+ let envelope: BlobEnvelope = serde_json::from_str(envelope_json)?;
+ envelope.verify()?;
+ let app_config = serde_json::from_value(envelope.data)?;
+ let envelope_json = serialize_app_config_envelope(app_config, origin_url, public_origin)?;
+ contents[CONFIG_BLOB_KEY] = toml::Value::String(envelope_json);
+ Ok(toml::to_string(&config)?)
+}
+
fn inject_generated_config_stores(template: &str, envelope_json: &str) -> Result {
let marker_count = template.matches(GENERATED_STORES_MARKER).count();
if marker_count != 1 {
@@ -380,9 +438,10 @@ mod tests {
.expect("should write controlled trace app config");
run(&Args {
template,
- app_config,
+ app_config: Some(app_config),
output: output.clone(),
origin_url: None,
+ public_origin: Some("http://127.0.0.1:43130".to_string()),
bidder_origin_url: Some(origin.to_string()),
})?;
Ok(toml::from_str(&fs::read_to_string(output)?)?)
@@ -406,6 +465,18 @@ mod tests {
"should keep every runtime-only alias local"
);
}
+ let retargeted = retarget_generated_config(
+ &toml::to_string(&generated).expect("should serialize generated config"),
+ None,
+ Some("http://127.0.0.1:43131"),
+ )
+ .expect("should retarget controlled bidder config");
+ let retargeted: toml::Value =
+ toml::from_str(&retargeted).expect("should parse retargeted bidder config");
+ assert_eq!(
+ retargeted["local_server"]["backends"], generated["local_server"]["backends"],
+ "should preserve controlled bidder aliases when retargeting the runtime listener"
+ );
let envelope: serde_json::Value = serde_json::from_str(
generated["local_server"]["config_stores"][DEFAULT_CONFIG_STORE_ID]["contents"]
[CONFIG_BLOB_KEY]
@@ -413,6 +484,10 @@ mod tests {
.expect("should retain validated app envelope"),
)
.expect("should read unchanged app config envelope");
+ assert_eq!(
+ envelope["data"]["publisher"]["public_origin"], "http://127.0.0.1:43130",
+ "should support a runtime public origin alongside controlled bidder aliases"
+ );
assert_eq!(
envelope["data"]["auction"]["providers"]["example"]["endpoint"],
"https://bidder.example.com/api/trace-bidder",
@@ -485,18 +560,120 @@ mod tests {
args,
Args {
template: PathBuf::from("template.toml"),
- app_config: PathBuf::from("trusted-server.toml"),
+ app_config: Some(PathBuf::from("trusted-server.toml")),
output: PathBuf::from("generated.toml"),
origin_url: Some("http://127.0.0.1:9999".to_string()),
+ public_origin: None,
bidder_origin_url: None,
},
"should parse expected args"
);
}
+ #[test]
+ fn parse_args_rejects_bidder_injection_without_app_config() {
+ let result = parse_args([
+ "--template".to_string(),
+ "generated.toml".to_string(),
+ "--output".to_string(),
+ "runtime.toml".to_string(),
+ "--bidder-origin-url".to_string(),
+ "http://127.0.0.1:8888".to_string(),
+ ]);
+ assert!(
+ result.is_err(),
+ "should not silently ignore bidder injection when retargeting an existing blob"
+ );
+ }
+
+ #[test]
+ fn parse_args_accepts_retargeting_an_existing_config() {
+ let args = parse_args([
+ "--template".to_string(),
+ "generated.toml".to_string(),
+ "--output".to_string(),
+ "runtime.toml".to_string(),
+ "--public-origin".to_string(),
+ "http://127.0.0.1:43125".to_string(),
+ ])
+ .expect("should parse runtime retargeting args");
+
+ assert_eq!(
+ args.app_config, None,
+ "should reuse the embedded app config"
+ );
+ assert_eq!(
+ args.public_origin.as_deref(),
+ Some("http://127.0.0.1:43125"),
+ "should parse the runtime listener origin"
+ );
+ }
+
+ #[test]
+ fn generated_blob_applies_public_origin_independently_of_upstream() {
+ let json = build_app_config_envelope(
+ APP_CONFIG,
+ Some("http://127.0.0.1:9999"),
+ Some("http://127.0.0.1:43126"),
+ )
+ .expect("should build runtime-specific config");
+ let envelope: BlobEnvelope =
+ serde_json::from_str(&json).expect("should parse runtime-specific envelope");
+ envelope
+ .verify()
+ .expect("should verify runtime-specific envelope");
+ assert_eq!(
+ envelope.data["publisher"]["origin_url"], "http://127.0.0.1:9999",
+ "should apply the upstream origin override"
+ );
+ assert_eq!(
+ envelope.data["publisher"]["public_origin"], "http://127.0.0.1:43126",
+ "should apply the public listener origin override"
+ );
+ }
+
+ #[test]
+ fn retargeting_generated_config_preserves_resources_and_app_policy() {
+ let json = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999"), None)
+ .expect("should build original envelope");
+ let generated = inject_generated_config_stores(TEMPLATE, &json)
+ .expect("should generate original config");
+ let original: toml::Value =
+ toml::from_str(&generated).expect("should parse original config");
+
+ for port in [43127, 43128] {
+ let public_origin = format!("http://127.0.0.1:{port}");
+ let retargeted = retarget_generated_config(&generated, None, Some(&public_origin))
+ .expect("should retarget existing config");
+ let mut retargeted: toml::Value =
+ toml::from_str(&retargeted).expect("should parse retargeted config");
+ let contents = &mut retargeted["local_server"]["config_stores"]
+ [DEFAULT_CONFIG_STORE_ID]["contents"];
+ let envelope: BlobEnvelope = serde_json::from_str(
+ contents[CONFIG_BLOB_KEY]
+ .as_str()
+ .expect("should contain a blob"),
+ )
+ .expect("should parse retargeted envelope");
+ envelope
+ .verify()
+ .expect("should verify retargeted envelope");
+ let mut expected: BlobEnvelope =
+ serde_json::from_str(&json).expect("should parse original envelope");
+ expected.data["publisher"]["public_origin"] = serde_json::json!(public_origin);
+ assert_eq!(
+ envelope.data, expected.data,
+ "should change only public_origin"
+ );
+ contents[CONFIG_BLOB_KEY] = toml::Value::String(json.clone());
+ assert_eq!(retargeted, original, "should preserve Viceroy resources");
+ }
+ }
+
#[test]
fn generated_config_contains_blob_without_removed_rollout_flags() {
- let envelope = build_app_config_envelope(APP_CONFIG, None).expect("should build envelope");
+ let envelope =
+ build_app_config_envelope(APP_CONFIG, None, None).expect("should build envelope");
let generated = inject_generated_config_stores(TEMPLATE, &envelope)
.expect("should inject generated stores");
@@ -522,7 +699,8 @@ mod tests {
#[test]
fn generated_config_is_valid_toml() {
- let envelope = build_app_config_envelope(APP_CONFIG, None).expect("should build envelope");
+ let envelope =
+ build_app_config_envelope(APP_CONFIG, None, None).expect("should build envelope");
let generated = inject_generated_config_stores(TEMPLATE, &envelope)
.expect("should inject generated stores");
let parsed: toml::Value = toml::from_str(&generated).expect("should parse as TOML");
@@ -538,7 +716,7 @@ mod tests {
#[test]
fn generated_blob_verifies_and_applies_origin_override() {
- let envelope = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999"))
+ let envelope = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999"), None)
.expect("should build envelope");
let settings = settings_from_config_blob(
&envelope,
@@ -555,7 +733,7 @@ mod tests {
#[test]
fn invalid_app_config_fails() {
- let result = build_app_config_envelope("not valid toml", None);
+ let result = build_app_config_envelope("not valid toml", None, None);
assert!(result.is_err(), "should reject invalid app config");
}
@@ -564,7 +742,7 @@ mod tests {
fn invalid_non_secret_app_config_fails_before_envelope_generation() {
let invalid = APP_CONFIG.replace("domain = \"localhost\"", "domain = \"invalid/domain\"");
- let err = build_app_config_envelope(&invalid, None)
+ let err = build_app_config_envelope(&invalid, None, None)
.expect_err("should reject invalid non-secret config before creating an envelope");
assert!(
@@ -573,6 +751,16 @@ mod tests {
);
}
+ #[test]
+ fn retargeting_a_template_without_a_generated_blob_fails() {
+ let result = retarget_generated_config(TEMPLATE, None, Some("http://127.0.0.1:43129"));
+
+ assert!(
+ result.is_err(),
+ "should reject config without an embedded app-config blob"
+ );
+ }
+
#[test]
fn missing_marker_fails() {
let result = inject_generated_config_stores("[local_server]", "{}");
diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs
index c7f90485cf..ea48162c8b 100644
--- a/crates/trusted-server-integration-tests/tests/common/config.rs
+++ b/crates/trusted-server-integration-tests/tests/common/config.rs
@@ -1,7 +1,7 @@
use edgezero_core::blob_envelope::BlobEnvelope;
use error_stack::Report;
use trusted_server_core::config::TrustedServerAppConfig;
-use trusted_server_core::config_payload::CONFIG_BLOB_KEY;
+use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID};
use crate::common::runtime::{TestError, TestResult};
@@ -95,6 +95,63 @@ pub fn cloudflare_auction_config_json(origin_port: u16) -> TestResult {
cloudflare_binding_json(&serde_json::json!({ CONFIG_BLOB_KEY: envelope }))
}
+/// Retargets an existing fixture without changing its policies or secret references.
+pub fn envelope_with_public_origin(envelope_json: &str, public_origin: &str) -> TestResult {
+ let envelope: BlobEnvelope = serde_json::from_str(envelope_json)
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(error.to_string()))?;
+ envelope
+ .verify()
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(error.to_string()))?;
+ let app_config: TrustedServerAppConfig = serde_json::from_value(envelope.data)
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(error.to_string()))?;
+ let mut settings = app_config.into_settings();
+ settings.publisher.public_origin = Some(public_origin.to_owned());
+ let app_config = TrustedServerAppConfig::new(settings)
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(format!("{error:?}")))?;
+ let data = serde_json::to_value(app_config)
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(error.to_string()))?;
+ serde_json::to_string(&BlobEnvelope::new(data, envelope.generated_at))
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(error.to_string()))
+}
+
+pub fn cloudflare_config_with_public_origin(
+ config_json: &str,
+ public_origin: &str,
+) -> TestResult {
+ let mut binding: serde_json::Value = serde_json::from_str(config_json)
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(error.to_string()))?;
+ let envelope = binding[CONFIG_BLOB_KEY].as_str().ok_or_else(|| {
+ Report::new(TestError::ConfigGeneration).attach("missing Cloudflare app-config blob")
+ })?;
+ binding[CONFIG_BLOB_KEY] =
+ serde_json::Value::String(envelope_with_public_origin(envelope, public_origin)?);
+ cloudflare_binding_json(&binding)
+}
+
+pub fn viceroy_config_with_public_origin(config: &str, public_origin: &str) -> TestResult {
+ let mut config: toml::Value = toml::from_str(config)
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(error.to_string()))?;
+ let contents = config
+ .get_mut("local_server")
+ .and_then(|value| value.get_mut("config_stores"))
+ .and_then(|value| value.get_mut(DEFAULT_CONFIG_STORE_ID))
+ .and_then(|value| value.get_mut("contents"))
+ .and_then(toml::Value::as_table_mut)
+ .ok_or_else(|| {
+ Report::new(TestError::ConfigGeneration).attach("missing Viceroy app-config store")
+ })?;
+ let envelope = contents
+ .get(CONFIG_BLOB_KEY)
+ .and_then(toml::Value::as_str)
+ .ok_or_else(|| {
+ Report::new(TestError::ConfigGeneration).attach("missing Viceroy app-config blob")
+ })?;
+ contents[CONFIG_BLOB_KEY] =
+ toml::Value::String(envelope_with_public_origin(envelope, public_origin)?);
+ toml::to_string(&config)
+ .map_err(|error| Report::new(TestError::ConfigGeneration).attach(error.to_string()))
+}
+
fn table_mut<'a>(table: &'a mut toml::Table, key: &str) -> TestResult<&'a mut toml::Table> {
table
.get_mut(key)
@@ -165,6 +222,124 @@ mod tests {
const VICEROY_TEMPLATE: &str = include_str!("../../fixtures/configs/viceroy-template.toml");
const LOGICAL_SECRET_STORE_ID: &str = "trusted_server_secrets";
+ #[test]
+ fn integration_config_uses_each_runtime_origin_without_changing_upstream() {
+ for port in [43121, 43122] {
+ let public_origin = format!("http://127.0.0.1:{port}");
+ let original =
+ integration_app_config_envelope(9999).expect("should build baseline config");
+ let json = envelope_with_public_origin(&original, &public_origin)
+ .expect("should build runtime-specific config");
+ let envelope: BlobEnvelope =
+ serde_json::from_str(&json).expect("should parse runtime-specific envelope");
+ envelope
+ .verify()
+ .expect("should verify runtime-specific envelope");
+
+ assert_eq!(
+ envelope.data["publisher"]["public_origin"], public_origin,
+ "should use the selected runtime listener"
+ );
+ assert_eq!(
+ envelope.data["publisher"]["origin_url"], "http://127.0.0.1:9999",
+ "should keep the upstream separate from the runtime listener"
+ );
+ }
+ }
+
+ #[test]
+ fn runtime_origin_preserves_custom_trace_config() {
+ let original = integration_app_config_envelope_with_trace(9999, true)
+ .expect("should build isolated trace config");
+ let retargeted = envelope_with_public_origin(&original, "http://127.0.0.1:43132")
+ .expect("should retarget trace config");
+ let mut before: BlobEnvelope =
+ serde_json::from_str(&original).expect("should parse original trace envelope");
+ let after: BlobEnvelope =
+ serde_json::from_str(&retargeted).expect("should parse retargeted trace envelope");
+ after
+ .verify()
+ .expect("should verify retargeted trace envelope");
+ before.data["publisher"]["public_origin"] = serde_json::json!("http://127.0.0.1:43132");
+ assert_eq!(
+ after.data, before.data,
+ "should preserve the isolated trace fixture and all policies when selecting a runtime origin"
+ );
+ assert_eq!(
+ after.data["integrations"]["gpt_diagnostics"]["trace_page_enabled"], true,
+ "should keep trace enabled for the custom runtime fixture"
+ );
+ }
+
+ #[test]
+ fn cloudflare_runtime_origin_preserves_custom_auction_config() {
+ let original = cloudflare_auction_config_json(9999).expect("should build auction config");
+ let retargeted = cloudflare_config_with_public_origin(&original, "http://127.0.0.1:43123")
+ .expect("should retarget auction config");
+ let original: serde_json::Value =
+ serde_json::from_str(&original).expect("should parse original binding");
+ let retargeted: serde_json::Value =
+ serde_json::from_str(&retargeted).expect("should parse retargeted binding");
+ let mut before: BlobEnvelope = serde_json::from_str(
+ original[CONFIG_BLOB_KEY]
+ .as_str()
+ .expect("should contain original blob"),
+ )
+ .expect("should parse original blob");
+ let after: BlobEnvelope = serde_json::from_str(
+ retargeted[CONFIG_BLOB_KEY]
+ .as_str()
+ .expect("should contain retargeted blob"),
+ )
+ .expect("should parse retargeted blob");
+ after.verify().expect("should verify retargeted blob");
+ before.data["publisher"]["public_origin"] = serde_json::json!("http://127.0.0.1:43123");
+ assert_eq!(
+ after.data, before.data,
+ "should change only the runtime public origin"
+ );
+ assert_eq!(
+ after.data["auction"]["enabled"], true,
+ "should preserve enabled auctions"
+ );
+ }
+
+ #[test]
+ fn viceroy_runtime_origin_preserves_resources_and_config_policy() {
+ let envelope = app_config_envelope(9999, false, |_| Ok(()))
+ .expect("should build legacy config without a public origin");
+ let stores = format!(
+ "[local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}]\nformat = \"inline-toml\"\n[local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}.contents]\n{CONFIG_BLOB_KEY} = '''{envelope}'''"
+ );
+ let config = VICEROY_TEMPLATE.replace("# GENERATED_TRUSTED_SERVER_CONFIG_STORES", &stores);
+ let original: toml::Value = toml::from_str(&config).expect("should parse original config");
+ let retargeted = viceroy_config_with_public_origin(&config, "http://127.0.0.1:43124")
+ .expect("should retarget Viceroy config");
+ let mut retargeted: toml::Value =
+ toml::from_str(&retargeted).expect("should parse retargeted Viceroy config");
+ let contents =
+ &mut retargeted["local_server"]["config_stores"][DEFAULT_CONFIG_STORE_ID]["contents"];
+ let after: BlobEnvelope = serde_json::from_str(
+ contents[CONFIG_BLOB_KEY]
+ .as_str()
+ .expect("should contain app-config blob"),
+ )
+ .expect("should parse retargeted blob");
+ after.verify().expect("should verify retargeted blob");
+ let mut before: BlobEnvelope =
+ serde_json::from_str(&envelope).expect("should parse original blob");
+ before.data["publisher"]["public_origin"] = serde_json::json!("http://127.0.0.1:43124");
+ assert_eq!(
+ after.data, before.data,
+ "should preserve upstream and config policy"
+ );
+ contents[CONFIG_BLOB_KEY] = toml::Value::String(envelope);
+ assert_eq!(
+ retargeted, original,
+ "should preserve all other Viceroy resources"
+ );
+ }
+
#[test]
fn local_fastly_config_defines_runtime_kv_stores() {
let parsed: toml::Value =
diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs
index fa9e389c43..98b56ff73c 100644
--- a/crates/trusted-server-integration-tests/tests/environments/axum.rs
+++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs
@@ -1,4 +1,4 @@
-use crate::common::config::integration_app_config_envelope;
+use crate::common::config::{envelope_with_public_origin, integration_app_config_envelope};
use crate::common::runtime::{
RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port,
};
@@ -76,6 +76,8 @@ impl AxumDevServer {
let binary = self.binary_path();
let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT);
+ let base_url = format!("http://127.0.0.1:{port}");
+ let app_config = envelope_with_public_origin(app_config, &base_url)?;
let store_name = default_config_store_name();
let config_key = default_config_key();
let config_variable = config_env_var(store_name.as_ref(), &config_key);
@@ -105,7 +107,6 @@ impl AxumDevServer {
}
let handle = AxumHandle { child };
- let base_url = format!("http://127.0.0.1:{port}");
// The Axum dev server returns 403 at root (no publisher config in test env),
// so we poll until we get any HTTP response rather than a specific status.
diff --git a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs
index 46a3197ec1..28213ca163 100644
--- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs
+++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs
@@ -1,4 +1,4 @@
-use crate::common::config::cloudflare_config_json;
+use crate::common::config::{cloudflare_config_json, cloudflare_config_with_public_origin};
use crate::common::runtime::{
RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port,
};
@@ -88,21 +88,21 @@ impl CloudflareWorkers {
&self,
ready: impl FnOnce(&str) -> TestResult<()>,
) -> TestResult {
+ let port = super::find_available_port().unwrap_or(CLOUDFLARE_DEFAULT_PORT);
+ let public_origin = format!("http://127.0.0.1:{port}");
let wrangler_dir = self.wrangler_dir();
let config = if std::env::var("CI").is_ok() {
- write_generated_ci_config(&wrangler_dir, &cloudflare_config_json(origin_port())?)?
+ let config_json = cloudflare_config_with_public_origin(
+ &cloudflare_config_json(origin_port())?,
+ &public_origin,
+ )?;
+ write_generated_ci_config(&wrangler_dir, &config_json)?
} else {
"wrangler.toml".to_string()
};
- self.spawn_wrangler(&wrangler_dir, &config, ready)
- }
-
- fn health_check_path(&self) -> &str {
- "/.well-known/trusted-server.json"
+ self.spawn_wrangler(&wrangler_dir, &config, port, ready)
}
-}
-impl CloudflareWorkers {
/// Start `wrangler dev` with a test-specific `TRUSTED_SERVER_CONFIG` binding.
///
/// Unlike [`RuntimeEnvironment::spawn`], this always renders the CI
@@ -115,9 +115,12 @@ impl CloudflareWorkers {
/// written or `wrangler dev` cannot be started, and
/// [`TestError::RuntimeNotReady`] if the health check times out.
pub fn spawn_with_config_json(&self, config_json: &str) -> TestResult {
+ let port = super::find_available_port().unwrap_or(CLOUDFLARE_DEFAULT_PORT);
+ let public_origin = format!("http://127.0.0.1:{port}");
+ let config_json = cloudflare_config_with_public_origin(config_json, &public_origin)?;
let wrangler_dir = self.wrangler_dir();
- let config = write_generated_ci_config(&wrangler_dir, config_json)?;
- self.spawn_wrangler(&wrangler_dir, &config, |base_url| {
+ let config = write_generated_ci_config(&wrangler_dir, &config_json)?;
+ self.spawn_wrangler(&wrangler_dir, &config, port, |base_url| {
super::wait_for_ready(base_url, self.health_check_path(), true)
})
}
@@ -126,10 +129,9 @@ impl CloudflareWorkers {
&self,
wrangler_dir: &Path,
config: &str,
+ port: u16,
ready: impl FnOnce(&str) -> TestResult<()>,
) -> TestResult {
- let port = super::find_available_port().unwrap_or(CLOUDFLARE_DEFAULT_PORT);
-
#[cfg(unix)]
let child = {
use std::os::unix::process::CommandExt as _;
diff --git a/crates/trusted-server-integration-tests/tests/environments/fastly.rs b/crates/trusted-server-integration-tests/tests/environments/fastly.rs
index 124a380f6a..66e92e1758 100644
--- a/crates/trusted-server-integration-tests/tests/environments/fastly.rs
+++ b/crates/trusted-server-integration-tests/tests/environments/fastly.rs
@@ -1,3 +1,4 @@
+use crate::common::config::viceroy_config_with_public_origin;
use crate::common::runtime::{
RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult,
};
@@ -20,6 +21,7 @@ impl RuntimeEnvironment for FastlyViceroy {
fn spawn(&self, wasm_path: &Path) -> TestResult {
let port = super::find_available_port()?;
+ let base_url = format!("http://127.0.0.1:{port}");
let viceroy_config = self.viceroy_config_path();
if !viceroy_config.exists() {
@@ -29,10 +31,24 @@ impl RuntimeEnvironment for FastlyViceroy {
)));
}
+ let config =
+ std::fs::read_to_string(&viceroy_config).change_context(TestError::ConfigGeneration)?;
+ let config = viceroy_config_with_public_origin(&config, &base_url)?;
+ // Keep relative resource paths anchored beside the supplied config.
+ let runtime_config = tempfile::NamedTempFile::new_in(
+ viceroy_config
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ .unwrap_or_else(|| Path::new(".")),
+ )
+ .change_context(TestError::ConfigGeneration)?;
+ std::fs::write(runtime_config.path(), config)
+ .change_context(TestError::ConfigGeneration)?;
+
let mut child = Command::new("viceroy")
.arg(wasm_path)
.arg("-C")
- .arg(&viceroy_config)
+ .arg(runtime_config.path())
.arg("--addr")
.arg(format!("127.0.0.1:{port}"))
.stdout(Stdio::null())
@@ -53,8 +69,10 @@ impl RuntimeEnvironment for FastlyViceroy {
}
// Wrap immediately so Drop::drop kills the process if readiness check fails
- let handle = ViceroyHandle { child };
- let base_url = format!("http://127.0.0.1:{port}");
+ let handle = ViceroyHandle {
+ child,
+ _runtime_config: runtime_config,
+ };
// Fastly exposes a dedicated `/health` route, so root fallback only
// adds redundant requests while the runtime is still starting up.
@@ -94,6 +112,7 @@ impl FastlyViceroy {
/// preventing orphaned Viceroy processes.
struct ViceroyHandle {
child: Child,
+ _runtime_config: tempfile::NamedTempFile,
}
impl RuntimeProcessHandle for ViceroyHandle {}
diff --git a/crates/trusted-server-integration-tests/tests/html_eof_runtime.rs b/crates/trusted-server-integration-tests/tests/html_eof_runtime.rs
index 2dd89e0683..166a80980b 100644
--- a/crates/trusted-server-integration-tests/tests/html_eof_runtime.rs
+++ b/crates/trusted-server-integration-tests/tests/html_eof_runtime.rs
@@ -596,10 +596,12 @@ fn actual_runtime() -> HarnessResult<()> {
cases.push("streaming");
}
let mut baseline = Vec::new();
+ let mut baseline_origin_stamp = String::new();
for case in cases {
harness.cancellation.check()?;
+ let port = available_port()?;
// Auction controls deliberately use an unreachable bidder and verify empty-bid assembly.
- let config = config::fixture_config(&template, case, origin.port, available_port()?)?;
+ let config = config::fixture_config(&template, case, origin.port, available_port()?, port)?;
fs::write(harness.workspace.join("trusted-server.toml"), config)
.change_context(HarnessError::Io)?;
fs::write(harness.workspace.join("fastly.toml"), "manifest_version = 3\nname = 'example-finalization'\nlanguage = 'rust'\n[local_server]\n").change_context(HarnessError::Io)?;
@@ -607,7 +609,6 @@ fn actual_runtime() -> HarnessResult<()> {
if matches!(adapter.as_str(), "fastly" | "spin") {
harness.push(&adapter)?;
}
- let port = available_port()?;
let log = harness.workspace.join(format!("{case}.log"));
let output = File::create(&log).change_context(HarnessError::Io)?;
let mut command = harness.runtime_command(&adapter, &artifact, port)?;
@@ -678,6 +679,28 @@ fn actual_runtime() -> HarnessResult<()> {
"should serve {case} HTML; see {}",
log.display()
);
+ let origin_stamp = format!("data-ts-public-origin=\"{base_url}\"");
+ assert_eq!(
+ String::from_utf8_lossy(&body)
+ .matches(&origin_stamp)
+ .count(),
+ 1,
+ "should stamp the configured origin of this case's listener"
+ );
+ // Cases use separate listeners. Only their declared runtime-origin stamp
+ // may differ before applying the approved EOF transformation.
+ let expected_baseline = if baseline.is_empty() {
+ Vec::new()
+ } else {
+ let html = std::str::from_utf8(&baseline).expect("should retain UTF-8 baseline HTML");
+ assert_eq!(
+ html.matches(&baseline_origin_stamp).count(),
+ 1,
+ "should retain exactly one baseline runtime-origin stamp"
+ );
+ html.replace(&baseline_origin_stamp, &origin_stamp)
+ .into_bytes()
+ };
match case {
"disabled" => {
assert!(
@@ -686,11 +709,12 @@ fn actual_runtime() -> HarnessResult<()> {
"should preserve disabled output"
);
baseline = body.clone();
+ baseline_origin_stamp = origin_stamp;
}
"success" => {
assert_eq!(
body,
- String::from_utf8_lossy(&baseline)
+ String::from_utf8_lossy(&expected_baseline)
.replace("EXAMPLE_UNSIGNED", "EXAMPLE_FINALIZED")
.as_bytes(),
"should emit the exact approved rewrite"
@@ -701,7 +725,7 @@ fn actual_runtime() -> HarnessResult<()> {
);
}
"streaming" => {
- let mut expected = baseline.clone();
+ let mut expected = expected_baseline;
expected.extend_from_slice(b"");
assert_eq!(body, expected, "should append only the EOF output");
}
@@ -722,8 +746,8 @@ fn actual_runtime() -> HarnessResult<()> {
}
_ => {
assert_eq!(
- body, baseline,
- "should emit byte-identical timeout fallback"
+ body, expected_baseline,
+ "should emit byte-identical timeout fallback apart from its listener stamp"
);
assert!(
(Duration::from_millis(600)..Duration::from_secs(3))
diff --git a/crates/trusted-server-integration-tests/tests/html_eof_runtime/config.rs b/crates/trusted-server-integration-tests/tests/html_eof_runtime/config.rs
index b6525c61e7..127ae83c40 100644
--- a/crates/trusted-server-integration-tests/tests/html_eof_runtime/config.rs
+++ b/crates/trusted-server-integration-tests/tests/html_eof_runtime/config.rs
@@ -59,12 +59,14 @@ pub(super) fn fixture_config(
case: &str,
origin: u16,
bid: u16,
+ public_port: u16,
) -> HarnessResult {
let mut value = parse(template)?;
for (key, replacement) in [
("domain", "127.0.0.1".to_owned()),
("cookie_domain", "127.0.0.1".to_owned()),
("origin_url", format!("http://127.0.0.1:{origin}")),
+ ("public_origin", format!("http://127.0.0.1:{public_port}")),
] {
replace(&mut value, &["publisher", key], Value::String(replacement))?;
}
@@ -113,7 +115,7 @@ pub(super) fn fixture_config(
case == "streaming"
))?;
table(&mut value, &["integrations"])?.insert("html_finalization_fixture".to_owned(), fixture);
- validate_fixture(&value, case, origin, bid)?;
+ validate_fixture(&value, case, origin, bid, public_port)?;
serialize(&value)
}
@@ -122,6 +124,7 @@ pub(super) fn validate_fixture(
case: &str,
origin: u16,
bid: u16,
+ public_port: u16,
) -> HarnessResult<()> {
let lookup = if matches!(case, "auction" | "streaming") {
"success"
@@ -141,6 +144,10 @@ pub(super) fn validate_fixture(
vec!["publisher", "origin_url"],
Value::String(format!("http://127.0.0.1:{origin}")),
),
+ (
+ vec!["publisher", "public_origin"],
+ Value::String(format!("http://127.0.0.1:{public_port}")),
+ ),
(
vec!["publisher", "allow_async_html_finish"],
Value::Boolean(case != "disabled"),
@@ -246,7 +253,7 @@ mod tests {
#[test]
fn fixture_config_is_loopback_only_with_explicit_permission() {
for case in ["disabled", "success", "hung", "auction", "streaming"] {
- let config = fixture_config(CONFIG, case, 12345, 12346)
+ let config = fixture_config(CONFIG, case, 12345, 12346, 12347)
.expect("should generate isolated configuration");
let parsed: toml::Value = toml::from_str(&config).expect("should parse configuration");
assert_eq!(
@@ -259,6 +266,11 @@ mod tests {
Some("http://127.0.0.1:12345"),
"should isolate the origin"
);
+ assert_eq!(
+ parsed["publisher"]["public_origin"].as_str(),
+ Some("http://127.0.0.1:12347"),
+ "should use the runtime listener, not the upstream origin"
+ );
assert_eq!(
parsed["publisher"]["allow_async_html_finish"].as_bool(),
Some(case != "disabled"),
@@ -279,13 +291,14 @@ mod tests {
"success",
12345,
12346,
+ 12347,
);
assert!(result.is_err(), "should reject incomplete templates");
}
#[test]
fn fixture_validation_rejects_external_endpoints() {
- let config = fixture_config(CONFIG, "auction", 12345, 12346)
+ let config = fixture_config(CONFIG, "auction", 12345, 12346, 12347)
.expect("should generate auction configuration");
let mut value = parse(&config).expect("should parse generated configuration");
let provider = table(&mut value, &["auction", "providers"])
@@ -301,7 +314,7 @@ mod tests {
)
.expect("should change the controlled endpoint");
assert!(
- validate_fixture(&value, "auction", 12345, 12346).is_err(),
+ validate_fixture(&value, "auction", 12345, 12346, 12347).is_err(),
"should reject non-loopback auction endpoints"
);
}
diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs
index 48bbe5191e..60d4f44955 100644
--- a/crates/trusted-server-integration-tests/tests/parity.rs
+++ b/crates/trusted-server-integration-tests/tests/parity.rs
@@ -440,6 +440,32 @@ async fn discovery_route_body_is_json_parity() {
}
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn proxy_rebuild_get_route_parity() {
+ let (axum_status, _) = axum_get("/first-party/proxy-rebuild").await;
+ let (cf_status, _) = cf_get("/first-party/proxy-rebuild").await;
+ let (spin_status, _) = spin_get("/first-party/proxy-rebuild").await;
+
+ for (adapter, status) in [
+ ("Axum", axum_status),
+ ("Cloudflare", cf_status),
+ ("Spin", spin_status),
+ ] {
+ assert_ne!(
+ status, 404,
+ "{adapter} GET /first-party/proxy-rebuild must be routed"
+ );
+ }
+ assert_eq!(
+ axum_status, cf_status,
+ "GET rebuild status must match: axum={axum_status} cf={cf_status}"
+ );
+ assert_eq!(
+ cf_status, spin_status,
+ "GET rebuild status must match: cf={cf_status} spin={spin_status}"
+ );
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn verify_signature_route_parity() {
// known divergence: without real signing-key configuration the handler may
diff --git a/crates/trusted-server-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts
index e0f25a807a..6ff758010e 100644
--- a/crates/trusted-server-js/lib/src/core/render.ts
+++ b/crates/trusted-server-js/lib/src/core/render.ts
@@ -221,13 +221,17 @@ export function createAdIframe(
return iframe;
}
-// Origin the creative runtime resolves root-relative first-party URLs against.
+// Parent-origin base for legacy relative clicks and the creative endpoint
+// fallback when no usable runtime script origin is available.
//
// The srcdoc document has an opaque origin and an `about:srcdoc` location, so it
// has no usable origin of its own; `document.baseURI` would work but is
-// inherited and honours a publisher ``, i.e. it is not a trustworthy
-// security boundary. This page — first-party, non-opaque — knows the real
-// origin, so it stamps it into the document ahead of any creative markup.
+// inherited and honours a publisher ``. This page knows its own origin,
+// so it stamps that value ahead of any creative markup. The non-writable stamp
+// keeps this base fixed; it does not authenticate the endpoint origin. Endpoints
+// prefer the runtime script origin, which earlier creative script can spoof by
+// shadowing document.currentScript. The iframe sandbox, not origin selection,
+// remains the security boundary.
//
// Only an exact `scheme://host[:port]` shape is emitted, so the value cannot
// break out of the quoted string it is written into.
diff --git a/crates/trusted-server-js/lib/src/core/templates/iframe.html b/crates/trusted-server-js/lib/src/core/templates/iframe.html
index 86aab282dd..2e190dd6bc 100644
--- a/crates/trusted-server-js/lib/src/core/templates/iframe.html
+++ b/crates/trusted-server-js/lib/src/core/templates/iframe.html
@@ -3,12 +3,11 @@
-
+
```
### Media Elements
@@ -269,7 +282,7 @@ it does not control whether the processed `adm` is present.
@@ -293,10 +306,10 @@ it does not control whether the processed `adm` is present.
```
@@ -312,11 +325,11 @@ it does not control whether the processed `adm` is present.
```html
-
+
```
@@ -349,11 +362,11 @@ If the iframe content itself contains HTML, it will be processed recursively. Ea
```
@@ -378,8 +391,8 @@ If the iframe content itself contains HTML, it will be processed recursively. Ea
Buy Now
```
@@ -409,10 +422,10 @@ Anchors (``) use `/first-party/click` for 302 redirects, avoiding content dow
```
@@ -430,7 +443,7 @@ Anchors (``) use `/first-party/click` for 302 redirects, avoiding content dow
```
@@ -457,10 +470,10 @@ Anchors (``) use `/first-party/click` for 302 redirects, avoiding content dow
```
@@ -493,7 +506,7 @@ Anchors (``) use `/first-party/click` for 302 redirects, avoiding content dow
```
Original: //cdn.example.com/script.js
Normalized: https://cdn.example.com/script.js
-Rewritten: /first-party/proxy?tsurl=https://cdn.example.com/script.js&tstoken=sig
+Rewritten: https://ads.publisher.example/first-party/proxy?tsurl=https://cdn.example.com/script.js&tstoken=sig
```
### Relative URLs
@@ -584,18 +597,18 @@ Descriptors are preserved exactly as written:
```
@@ -606,13 +619,13 @@ Srcset can mix absolute and relative URLs:
```html
-
+
```
@@ -645,23 +658,23 @@ Common properties with `url()` values:
```css
/* Background images */
-background: url(https://cdn.com/bg.jpg);
-background-image: url(https://cdn.com/pattern.png);
+background: url(https://cdn.example.com/bg.jpg);
+background-image: url(https://cdn.example.com/pattern.png);
/* Borders */
-border-image: url(https://cdn.com/border.svg);
+border-image: url(https://cdn.example.com/border.svg);
/* List styles */
-list-style-image: url(https://cdn.com/bullet.png);
+list-style-image: url(https://cdn.example.com/bullet.png);
/* Cursors */
-cursor: url(https://cdn.com/cursor.cur), pointer;
+cursor: url(https://cdn.example.com/cursor.cur), pointer;
/* Masks */
-mask-image: url(https://cdn.com/mask.svg);
+mask-image: url(https://cdn.example.com/mask.svg);
/* Filters */
-filter: url(https://cdn.com/filter.svg#blur);
+filter: url(https://cdn.example.com/filter.svg#blur);
```
**All `url()` occurrences are rewritten** regardless of property.
@@ -674,15 +687,17 @@ Properties can have multiple `url()` values:
/* Original */
.element {
background:
- url(https://cdn.com/top.png) top,
- url(https://cdn.com/bottom.png) bottom;
+ url(https://cdn.example.com/top.png) top,
+ url(https://cdn.example.com/bottom.png) bottom;
}
/* Rewritten */
.element {
background:
- url(/first-party/proxy?tsurl=https://cdn.com/top.png&tstoken=sig1) top,
- url(/first-party/proxy?tsurl=https://cdn.com/bottom.png&tstoken=sig2) bottom;
+ url(https://ads.publisher.example/first-party/proxy?tsurl=https://cdn.example.com/top.png&tstoken=sig1)
+ top,
+ url(https://ads.publisher.example/first-party/proxy?tsurl=https://cdn.example.com/bottom.png&tstoken=sig2)
+ bottom;
}
```
@@ -692,10 +707,10 @@ CSS `@import` with URLs:
```css
/* Original */
-@import url(https://fonts.googleapis.com/css?family=Roboto);
+@import url(https://fonts.example.com/css?family=Roboto);
/* Rewritten */
-@import url(/first-party/proxy?tsurl=https://fonts.googleapis.com/css?family=Roboto&tstoken=sig);
+@import url(https://ads.publisher.example/first-party/proxy?tsurl=https://fonts.example.com/css?family=Roboto&tstoken=sig);
```
## Exclude Domains
@@ -745,7 +760,7 @@ Matches:
**Trusted Partners**:
```toml
-exclude_domains = ["*.trusted-cdn.com"]
+exclude_domains = ["*.trusted-cdn.example.com"]
```
Skip rewriting for partners already providing first-party scripts.
@@ -901,6 +916,11 @@ The Trusted Server JavaScript (TSJS) library is automatically injected:
**Timing**: Injected **once per HTML response** before any other scripts.
+The tag above illustrates publisher HTML injection. Creative HTML injection
+uses an absolute URL such as
+`https://ads.publisher.example/static/tsjs=tsjs-unified.min.js?v=`.
+Changing the URL authority does not change the selected modules or hash.
+
### Integration bundles
The integration registry selects TSJS modules by enabled integration ID. A
@@ -922,7 +942,10 @@ Each integration is built as a separate IIFE at compile time (into Cargo's `OUT_
- Prebid is built externally with `build-prebid-external.mjs` and served through `/integrations/prebid/bundle.js`
- `tsjs-lockr.js`, `tsjs-permutive.js`, `tsjs-didomi.js`, `tsjs-datadome.js`, `tsjs-testlight.js` — Other integrations
-At runtime, the server concatenates `tsjs-core.js` + the modules for enabled integrations. The URL stays `/static/tsjs=tsjs-unified.min.js?v=` for backward compatibility.
+At runtime, the server concatenates `tsjs-core.js` + the modules for enabled integrations.
+The path stays `/static/tsjs=tsjs-unified.min.js?v=`. Injected creative
+script URLs prefix that path with the configured public origin. Bundle
+selection and hash calculation are unchanged.
## Performance Optimization
diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md
index 1e0f782fda..4e94b7ef41 100644
--- a/docs/guide/first-party-proxy.md
+++ b/docs/guide/first-party-proxy.md
@@ -12,13 +12,24 @@ The First-Party Proxy system rewrites third-party URLs in ad creatives to route
- **Click Tracking** - First-party click redirects
- **Content Security** - Validated, signed URLs prevent tampering
+## Generated URL authority
+
+Routes remain path-based, but URLs emitted into rewritten creatives and
+`/first-party/sign` or `/first-party/proxy-rebuild` responses are absolute on
+`[publisher].public_origin`, for example
+`https://ads.publisher.example/first-party/proxy?...`. Existing root-relative
+signed proxy and click inputs remain accepted. The publisher SSAT/page-bids
+path retains its explicit request-origin override. See
+[public origin configuration](/guide/configuration#public_origin) for the default
+authority, EC cookie scope, and coordinated upgrade/rollback warning.
+
## How It Works
```mermaid
flowchart TD
- original["`Creative (Original) <img src='tracker.com/pixel.gif' />`"]
- rewritten["Creative (Rewritten)
<img src='/first-party/proxy?
tsurl=https://tracker.com/
pixel.gif&tstoken=abc123...' />"]
- server["Trusted Server
1. Validate tstoken
2. Append ts-ec
3. Proxy to tracker.com
4. Return response"]
+ original["`Creative (Original) <img src='tracker.example.com/pixel.gif' />`"]
+ rewritten["Creative (Rewritten)
<img src='https://ads.publisher.example/first-party/proxy?
tsurl=https://tracker.example.com/
pixel.gif&tstoken=abc123...' />"]
+ server["Trusted Server
1. Validate tstoken
2. Append ts-ec
3. Proxy to tracker.example.com
4. Return response"]
original -->|Rewrite| rewritten -->|Browser Request| server
```
@@ -67,14 +78,14 @@ GET /first-party/proxy?tsurl=https://example.com/ad.html&tstoken=signature
Original URL:
```
-https://tracker.com/pixel.gif?campaign=123&uid=abc
+https://tracker.example.com/pixel.gif?campaign=123&uid=abc
```
Signed proxy URL:
```
-/first-party/proxy?
- tsurl=https://tracker.com/pixel.gif&
+https://ads.publisher.example/first-party/proxy?
+ tsurl=https://tracker.example.com/pixel.gif&
campaign=123&
uid=abc&
tstoken=HmacSha256Signature
@@ -83,7 +94,7 @@ Signed proxy URL:
Final proxied request:
```
-https://tracker.com/pixel.gif?campaign=123&uid=abc&ts-ec=xyz
+https://tracker.example.com/pixel.gif?campaign=123&uid=abc&ts-ec=xyz
```
### `/first-party/click` - Click Redirects
@@ -93,7 +104,7 @@ Handles click tracking with first-party redirects.
**Request**:
```
-GET /first-party/click?tsurl=https://advertiser.com/landing&tstoken=signature
+GET /first-party/click?tsurl=https://advertiser.example.com/landing&tstoken=signature
```
**Query Parameters**: Same as `/first-party/proxy`
@@ -116,8 +127,8 @@ Click URL in creative:
```html
@@ -129,7 +140,7 @@ User clicks → Server responds:
```
HTTP/1.1 302 Found
-Location: https://advertiser.com/buy?product=widget&ts-ec=xyz
+Location: https://advertiser.example.com/buy?product=widget&ts-ec=xyz
```
::: tip Click vs Proxy
@@ -159,7 +170,7 @@ POST /first-party/sign
```json
{
- "href": "/first-party/proxy?tsurl=https://example.com/resource.jpg¶m=value&tstoken=signature&tsexp=1234567890",
+ "href": "https://ads.publisher.example/first-party/proxy?tsurl=https://example.com/resource.jpg¶m=value&tstoken=signature&tsexp=1234567890",
"base": "https://example.com/resource.jpg"
}
```
@@ -199,7 +210,8 @@ Re-signs an existing signed click URL after creative script adds or removes
query parameters. The original `tstoken` is validated before any change is
applied, and `tsurl`, `tstoken`, and `tsexp` may never be added or deleted.
-Two request forms are supported, differing only in transport and response.
+JSON POST, GET navigation, and form-encoded POST navigation are supported,
+differing only in transport and response.
**POST (JSON) — used by the click guard on a same-origin page**:
@@ -224,7 +236,7 @@ Responds `200` with the rebuilt URL and diagnostics:
```json
{
- "href": "/first-party/click?tsurl=https%3A%2F%2Fexample.com&variant=red&tstoken=sig2",
+ "href": "https://ads.publisher.example/first-party/click?tsurl=https%3A%2F%2Fexample.com&variant=red&tstoken=sig2",
"base": "https://example.com",
"added": { "variant": "red" },
"removed": ["product"]
@@ -243,8 +255,9 @@ GET /first-party/proxy-rebuild?tsclick=&add=&del
| `add` | No | URL-encoded JSON object, e.g. `{"variant":"red"}` |
| `del` | No | URL-encoded JSON array, e.g. `["product"]` |
-Responds `302` with the rebuilt click in `Location` (plus
-`Cache-Control: no-store, private`), so the browser continues to
+Responds `302` with the absolute rebuilt click URL in `Location`, on
+`publisher.public_origin` or its `https://{publisher.domain}` fallback, with
+`Cache-Control: no-store, private`. The browser continues to
`/first-party/click`, which then redirects to the advertiser.
**Form-encoded POST (navigation, no URL length limit)**: a `POST` carrying
@@ -302,12 +315,12 @@ For the detailed signing algorithm, validation steps, and security notes, see [P
```css
/* Original */
.banner {
- background: url(https://cdn.com/bg.jpg);
+ background: url(https://cdn.example.com/bg.jpg);
}
/* Rewritten */
.banner {
- background: url(/first-party/proxy?tsurl=https://cdn.com/bg.jpg&tstoken=sig);
+ background: url(https://ads.publisher.example/first-party/proxy?tsurl=https://cdn.example.com/bg.jpg&tstoken=sig);
}
```
@@ -330,7 +343,7 @@ For the detailed signing algorithm, validation steps, and security notes, see [P
**Logging**:
```
-proxy: likely pixel detected size=43 url=https://tracker.com/p.gif
+proxy: likely pixel detected size=43 url=https://tracker.example.com/p.gif
```
### Passthrough (Other Types)
@@ -368,7 +381,7 @@ The proxy automatically follows HTTP redirects:
```
Request: /first-party/proxy?tsurl=https://short.link&tstoken=sig
- → 302 to https://cdn.com/ad.html
+ → 302 to https://cdn.example.com/ad.html
→ 200 with HTML content
→ Rewrite HTML and return
```
@@ -388,11 +401,11 @@ When proxying, Trusted Server automatically appends the `ts-ec` parameter:
```
Original request to proxy:
- /first-party/proxy?tsurl=https://tracker.com/pixel.gif&tstoken=sig
+ /first-party/proxy?tsurl=https://tracker.example.com/pixel.gif&tstoken=sig
Cookie: ts-ec=user123
Proxied backend request:
- https://tracker.com/pixel.gif?ts-ec=user123
+ https://tracker.example.com/pixel.gif?ts-ec=user123
```
### Redirect Propagation
@@ -416,14 +429,16 @@ This ensures downstream endpoints receive consistent IDs even through redirect c
Click redirects also forward EC IDs:
```html
-
+
```
User clicks → redirect includes ID:
```
302 Found
-Location: https://advertiser.com?ts-ec=user123
+Location: https://advertiser.example.com?ts-ec=user123
```
::: tip Forwarding Conditions
@@ -718,8 +733,8 @@ Proxy requests emit detailed logs:
```
proxy: origin response status=200 ct=text/html cl=1234 accept=text/html url=https://...
-proxy: likely pixel detected size=43 url=https://tracker.com/p.gif
-click: tsurl=https://advertiser.com had_params=true target=... referer=... ua=... tsid=...
+proxy: likely pixel detected size=43 url=https://tracker.example.com/p.gif
+click: tsurl=https://advertiser.example.com had_params=true target=... referer=... ua=... tsid=...
```
### Diagnostic Headers
diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md
index 8ff0b435cc..9e759697c5 100644
--- a/docs/guide/getting-started.md
+++ b/docs/guide/getting-started.md
@@ -79,6 +79,8 @@ the variables into your shell before starting the server.
# Create the local app config and apply the non-secret development overlay.
cp trusted-server.example.toml trusted-server.toml
set -a && source .env.dev && set +a
+# Axum listens on 8787, not the Viceroy port in .env.dev.
+export TRUSTED_SERVER__PUBLISHER__PUBLIC_ORIGIN=http://localhost:8787
# Use the repository's default app-config store name and key for this quickstart.
unset EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME
@@ -100,8 +102,10 @@ export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD="$(openssl
cargo run -p trusted-server-adapter-axum
```
-The server will be available at `http://localhost:8787`. Set `PORT=` before
-`cargo run` to bind the dev server to a different local port.
+The server will be available at `http://localhost:8787`. To use a different port,
+set `PORT=` before `cargo run` and set
+`TRUSTED_SERVER__PUBLISHER__PUBLIC_ORIGIN=http://localhost:` before
+`ts config push` so rewritten creative URLs use the same listener.
**Environment variable conventions used by the Axum adapter:**
diff --git a/docs/superpowers/specs/2026-10-29-absolute-creative-rewrite-urls-design.md b/docs/superpowers/specs/2026-10-29-absolute-creative-rewrite-urls-design.md
new file mode 100644
index 0000000000..b9a7c69dcf
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-29-absolute-creative-rewrite-urls-design.md
@@ -0,0 +1,206 @@
+# Absolute creative rewrite URLs
+
+## Intent and scope
+
+Fix [#915](https://github.com/IABTechLab/trusted-server/issues/915): a creative
+returned by Trusted Server must retain its generated proxy, click and runtime
+URLs when a different origin renders it. This records the scope approved for
+refreshing [PR #917](https://github.com/IABTechLab/trusted-server/pull/917).
+
+Keep the existing rewriter, signing format and routes. Change the origin of
+generated URLs and the creative runtime's endpoint resolution, not which assets
+or clicks policy permits rewriting.
+
+## Approaches
+
+1. Keep root-relative output and require every renderer to supply a trusted base.
+ This leaves external consumers dependent on document context and does not meet
+ the issue's acceptance criteria.
+2. Qualify output using an explicit browser-facing origin and capture the injected
+ script's origin for runtime endpoints. This is the approved refresh approach.
+ It preserves the current builders and supports ports, localhost and embedding
+ on other origins, but deployments must configure their reachable endpoint.
+3. Derive all output from each request's authority. This fits the existing inline
+ publisher renderer, but general creative/CSS builders lack that request and
+ external request authorities are not a substitute for configured ownership.
+ Retain the existing inline override rather than extending it to every path.
+
+## Configuration boundary
+
+Add optional `[publisher].public_origin`. It is the browser-facing Trusted Server
+origin, not the upstream `origin_url`. When absent, generated general-purpose
+URLs use `https://{publisher.domain}`. An absent value remains absent when
+serialized so legacy configuration blobs still load and default values do not
+introduce an unknown key into an old schema.
+
+The configured value must parse to an HTTP(S) origin with a host and optional
+port. Reject userinfo, non-root paths, query, fragment, whitespace/control
+characters and a trailing slash. Normalize an accepted parsed origin once at the
+shared configuration-loading boundary, before output or blob serialization.
+This includes repairing URL-parser-accepted spellings such as
+`https:ads.example.com` to `https://ads.example.com`, lowercasing host/scheme and
+removing default ports. Preserve non-default ports, IPv6 and HTTP development
+origins such as `http://localhost:7676`.
+
+Both `Settings` loading and `TrustedServerAppConfig` loading use this boundary.
+The CLI environment overlay still replaces only scalar leaves already present
+in TOML. An environment variable cannot create an omitted `public_origin` key.
+No global overlay behavior changes.
+
+App-config construction and deserialization emit advisory diagnostics after
+normalization. Warn for an omitted origin, an explicit host outside the EC cookie
+scope derived from `publisher.domain`, and explicit non-loopback HTTP. Preserve
+successful validation for these cases. `Validate::validate` remains free of
+logging, so validating the same object repeatedly does not repeat diagnostics.
+Use static messages without interpolating configuration values.
+
+## URL construction and rendering
+
+```mermaid
+flowchart TD
+ Config[Configured public origin or domain fallback] --> Normalize[Validated normalized origin]
+ Normalize --> General[General creative and proxied HTML/CSS output]
+ General --> URLs[Absolute proxy, click and runtime URLs]
+ Normalize --> Responses[Absolute sign and rebuild response URLs]
+ Inline[Publisher inline renderer request origin] --> Override[Existing explicit proxy/click origin override]
+ Script[Injected classic script origin] --> Endpoints[Runtime sign/rebuild endpoint origin]
+ Normalize --> Metadata[Runtime script data-ts-public-origin attribute]
+ Metadata --> Recognition[Exact already-proxied origin and path recognition]
+ Script --> Recognition
+ Stamp[Existing trusted parent stamp] --> Legacy[Fallback resolution for legacy relative navigation]
+```
+
+- Resolve the output origin at the rewrite boundary and pass it explicitly to
+ shared signed-URL builders. HTML attributes, CSS, `srcset` and
+ `/first-party/sign` share this rule without inferring origin choice from a
+ path's leading slash.
+- Qualify `/first-party/proxy-rebuild` JSON output and redirect `Location` with the
+ same configured public origin.
+- Use one creative runtime-tag generator for both `` insertion and the
+ body-less fragment fallback. Keep exactly one injection and the existing size
+ limits. Qualify the source selected by the current TSJS helper; do not change
+ bundle composition or hashing.
+- Keep the inline SSAT/page-bids path's explicit request-origin override and its
+ existing choice not to inject the creative runtime.
+- Preserve `rewrite_creatives`, `rewrite_clicks`, `include_domains`, exclusion
+ precedence and the distinct asset/click policies from current `main`.
+- Leave relative creative source URLs and non-network schemes unchanged. Keep
+ input/output bounds, optional sanitization and existing base-element handling.
+
+## Browser runtime and recovery
+
+Capture the classic script origin synchronously during module evaluation.
+Prefer that endpoint origin even when the document or trusted parent stamp has
+a different origin. If no usable script origin exists, use the existing pinned,
+normalized trusted base. An unusable opaque base supplies no endpoint origin.
+Later DOM or base-element mutations must not retarget captured endpoints.
+This captures a value, not an authenticated script identity. An earlier creative
+script can shadow `document.currentScript` before evaluation and retarget
+sign/rebuild endpoints, including GET rebuild navigation, while the parent stamp
+remains pinned. This accepted script-first contract does not promise protection
+against that pre-load spoofing. Browser coverage must distinguish it from later
+mutations and from attempts to overwrite the stamp itself.
+
+Use the endpoint origin for sign/rebuild requests. Both publisher-page and
+creative-runtime script tags carry the configured effective public origin in
+`data-ts-public-origin`; capture it synchronously alongside the script origin.
+Recognize an existing proxy URL only when its exact `/first-party/proxy` path is
+on the endpoint origin or this configured origin. The additional recognition
+origin does not retarget sign/rebuild endpoints or replace the legacy stamp.
+Missing or unusable metadata retains legacy behavior. Later attribute mutations
+must not change the captured origins. As with the script source, earlier scripts
+can spoof the metadata before capture; it is not an authenticated identity.
+Do not mistake another host's `/first-party/proxy` for a Trusted Server proxy URL.
+
+Keep successful rebuilt signed click values absolute in both `href` and
+`data-tsclick`. A later unchanged click or second mutation must not drop the
+public origin and navigate against the embedding document instead. Legacy
+root-relative click inputs remain supported by the server.
+
+Retain the existing stamped navigation base for legacy relative values. Do not
+merge its trust precedence with script-origin endpoint selection. Keep pending
+GET rebuild recovery, navigation scheme checks, opaque-origin no-fetch behavior
+and the long-URL form POST fallback. Network and non-policy signing failures
+still fall back to raw resources. A signing-policy `403` remains blocked and the
+dynamic guard must not apply the resource. JSON rebuild failures still use
+navigation recovery.
+
+## Signing, routes, consent and privacy
+
+Signatures continue to cover the clear destination and query parameters, not the
+first-party origin. Reuse existing absolute/root-relative input parsing and
+validate the original token before applying mutations. Keep reserved signing
+fields immutable and exact click-path validation. No signing format migration.
+
+All four adapters use the changed shared core. GET/POST rebuild routes already
+exist on Fastly, Axum, Cloudflare and Spin; this feature adds no route. Retain
+useful route/parity and legacy-input coverage without describing routing as new.
+
+This changes endpoint URL resolution, not identity or consent policy. No new
+identifiers or data collection, no wider rewrite host policy, and no change to
+server-side EC forwarding rules. Fetch keeps `credentials: 'same-origin'`.
+Actual cookie attachment can change when a configured endpoint differs from
+the document origin; this feature does not add cross-origin credentials.
+Use an endpoint within the EC cookie scope as described in
+[public origin configuration](/guide/configuration#public_origin). No CORS
+grants, signing broker, retry framework or dynamic-signing transport redesign;
+#982 remains separate.
+
+## Compatibility and rollout
+
+Follow the [public origin configuration warning](/guide/configuration#public_origin)
+for default authority changes and coordinated binary/config upgrade and rollback.
+Existing signed relative inputs remain accepted on the new binary.
+
+## Verification
+
+Creative rewrite tests should configure a distinct `publisher.public_origin`
+and assert the parsed URL origin, not only a `/first-party/...` path substring.
+Cover a deployment origin with a port and direct local development at
+`http://localhost:8080`. Keep old-config deserialization and omitted-field
+fallback tests separate from authority assertions, using the
+[configured fallback contract](/guide/configuration#public_origin).
+Keep manual root-relative proxy and click inputs for compatibility tests;
+rewritten creative output and sign/rebuild responses should be absolute. Test
+the publisher SSAT/page-bids request-origin override separately. Existing TSJS
+source selection and bundle hashes should remain unchanged.
+
+- A regression must first expose malformed-origin emission, then prove TOML and
+ JSON loads store and serialize a normalized origin and generated output resolves
+ to that origin from a foreign document.
+- Cover omission, invalid origin components, HTTP localhost, custom ports, IPv6,
+ blob round trips and present/absent TOML-leaf environment overrides.
+- Preserve current asset/click switch and include/exclude matrices. Update only
+ expected output authority where those tests pin root-relative generated URLs.
+- Test HTML, CSS, `srcset`, click, runtime-tag, sign and rebuild outputs, original
+ signatures, reserved fields, and absolute/legacy input compatibility.
+- Test a publisher bundle loaded from the document origin with a different
+ configured proxy origin. Enable image and iframe render guards and assert
+ generated proxy URLs remain unchanged with no extra signing requests. Cover
+ emitted independent IIFEs and a real browser using server-generated metadata.
+ Pin exact path, foreign host, ports, invalid/missing metadata, and post-capture
+ mutations while retaining script-origin-first endpoint selection.
+- Test distinct document/script/stamp origins, missing script, opaque srcdoc,
+ repeated JSON repair/navigation, raw-resource fallback, GET fallback and
+ long-URL form navigation. A mocked successful cross-origin POST test does not
+ imply the production server adds CORS support.
+- Generate each integration runtime's `public_origin` from its actual listener
+ scheme/host/port, independent of the upstream origin. Preserve custom Viceroy
+ resources and legacy source fixtures without the optional field. Cloudflare's
+ generated integration binding is retargeted; its existing non-CI,
+ operator-managed `wrangler.toml` path remains operator-configured.
+- Run the real browser sandbox GET rebuild/click redirect chain against the
+ configured runtime endpoint with both framework fixtures.
+- Run target-matched Rust tests, parity, lint/build gates, JS tests/build/lint,
+ formatting and changed-line URL-host lint before review handoff.
+
+## Review log
+
+- Round 1, independent Pi source review, 2026-10-29. Corrected the policy-403
+ fallback distinction and cookie-attachment claim. Clarified the operator-managed
+ Cloudflare path. Source review also verified the narrow constructor-normalization
+ fix and its regression test. No remaining code-design blockers identified.
+- Round 2, fresh independent Pi source review, 2026-10-29. Verified the corrected
+ failure/credential contracts, configuration boundaries, signing, origin
+ precedence, adapters and rollout against source and tests. No blocking findings.
+ Runtime evidence is recorded separately in the PR test results.
diff --git a/scripts/smoke-axum.sh b/scripts/smoke-axum.sh
index 3632dc3ff7..ae957e4ba6 100755
--- a/scripts/smoke-axum.sh
+++ b/scripts/smoke-axum.sh
@@ -16,6 +16,7 @@ smoke_require_command pgrep
WORKSPACE=$(smoke_make_workspace axum)
ORIGIN_PORT=${AXUM_SMOKE_ORIGIN_PORT:-18889}
BASE_PORT=${AXUM_SMOKE_PORT:-18890}
+POSITIVE_PORT=$((BASE_PORT + 4))
ORIGIN_PID=""
APP_PID=""
@@ -35,7 +36,8 @@ fi
smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT"
ORIGIN_PID=$SMOKE_ORIGIN_PID
-smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT"
+# The positive case uses the final listener in the case sequence.
+smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" "$POSITIVE_PORT"
sed \
-e "s|= \"crates/|= \"$REPO_ROOT/crates/|g" \
-e "s|manifest = \"fastly.toml\"|manifest = \"$REPO_ROOT/fastly.toml\"|" \
@@ -110,7 +112,7 @@ run_case missing-proxy "$((BASE_PORT + 2))" "$ENVELOPE" \
run_case missing-ec "$((BASE_PORT + 3))" "$ENVELOPE" \
"$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "" 500 \
"failed to resolve secret reference at \`ec.passphrase\`"
-run_case positive "$((BASE_PORT + 4))" "$ENVELOPE" \
+run_case positive "$POSITIVE_PORT" "$ENVELOPE" \
"$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 200
echo "Axum first-success smoke passed"
diff --git a/scripts/smoke-cloudflare.sh b/scripts/smoke-cloudflare.sh
index 161823217f..867504397b 100755
--- a/scripts/smoke-cloudflare.sh
+++ b/scripts/smoke-cloudflare.sh
@@ -24,6 +24,7 @@ ACTUAL_WRANGLER=$(wrangler --version | sed -nE 's/^([0-9]+\.[0-9]+\.[0-9]+)$/\1/
WORKSPACE=$(smoke_make_workspace cloudflare)
ORIGIN_PORT=${CLOUDFLARE_SMOKE_ORIGIN_PORT:-19089}
BASE_PORT=${CLOUDFLARE_SMOKE_PORT:-19080}
+POSITIVE_PORT=$((BASE_PORT + 4))
ORIGIN_PID=""
APP_PID=""
@@ -44,7 +45,8 @@ fi
smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT"
ORIGIN_PID=$SMOKE_ORIGIN_PID
-smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT"
+# The positive case uses the final listener in the case sequence.
+smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" "$POSITIVE_PORT"
CLOUDFLARE_WORK="$WORKSPACE/cloudflare"
mkdir -p "$CLOUDFLARE_WORK"
@@ -187,6 +189,6 @@ run_case missing-proxy "$((BASE_PORT + 2))" "$CONFIG_JSON" publisher_proxy_secre
run_case missing-ec "$((BASE_PORT + 3))" "$CONFIG_JSON" ec_passphrase 500 \
'env.ec_passphrase' \
"failed to resolve secret reference at \`ec.passphrase\` from secret store \`trusted_server_secrets\`"
-run_case positive "$((BASE_PORT + 4))" "$CONFIG_JSON" "" 200 "" ""
+run_case positive "$POSITIVE_PORT" "$CONFIG_JSON" "" 200 "" ""
echo "Cloudflare first-success smoke passed"
diff --git a/scripts/smoke-common.sh b/scripts/smoke-common.sh
index 4885472c47..2e69ab0e18 100644
--- a/scripts/smoke-common.sh
+++ b/scripts/smoke-common.sh
@@ -182,11 +182,13 @@ smoke_initialize_config() {
local repository="$1"
local workspace="$2"
local origin_port="$3"
+ local listener_port="$4"
SMOKE_APP_CONFIG="$workspace/trusted-server.toml"
"$SMOKE_TS_BIN" config init --app-config "$SMOKE_APP_CONFIG"
export TRUSTED_SERVER__PUBLISHER__DOMAIN="127.0.0.1"
export TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN="127.0.0.1"
export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$origin_port"
+ export TRUSTED_SERVER__PUBLISHER__PUBLIC_ORIGIN="http://127.0.0.1:$listener_port"
"$SMOKE_TS_BIN" config validate \
--manifest "$repository/edgezero.toml" \
--app-config "$SMOKE_APP_CONFIG" \
diff --git a/scripts/smoke-fastly.sh b/scripts/smoke-fastly.sh
index 65635c2d23..32de05bee7 100755
--- a/scripts/smoke-fastly.sh
+++ b/scripts/smoke-fastly.sh
@@ -19,6 +19,7 @@ FASTLY_MANIFEST="$FASTLY_PROJECT/fastly.toml"
EDGEZERO_MANIFEST="$FASTLY_PROJECT/edgezero.toml"
ORIGIN_PORT=${FASTLY_SMOKE_ORIGIN_PORT:-18989}
BASE_PORT=${FASTLY_SMOKE_PORT:-18980}
+POSITIVE_PORT=$((BASE_PORT + 4))
ORIGIN_PID=""
APP_PID=""
@@ -67,7 +68,8 @@ fi
smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT"
ORIGIN_PID=$SMOKE_ORIGIN_PID
-smoke_initialize_config "$FASTLY_PROJECT" "$WORKSPACE" "$ORIGIN_PORT"
+# The positive case uses the final listener in the case sequence.
+smoke_initialize_config "$FASTLY_PROJECT" "$WORKSPACE" "$ORIGIN_PORT" "$POSITIVE_PORT"
run_case() {
local case_name="$1"
@@ -177,6 +179,6 @@ run_case missing-ec "$((BASE_PORT + 3))" 500 \
"failed to resolve secret reference at \`ec.passphrase\`"
cp "$CONFIGURED_FASTLY" "$FASTLY_MANIFEST"
-run_case positive "$((BASE_PORT + 4))" 200
+run_case positive "$POSITIVE_PORT" 200
echo "Fastly first-success smoke passed"
diff --git a/scripts/smoke-spin.sh b/scripts/smoke-spin.sh
index 3384dcf03b..b12441aa2a 100755
--- a/scripts/smoke-spin.sh
+++ b/scripts/smoke-spin.sh
@@ -17,6 +17,7 @@ smoke_require_spin_version
WORKSPACE=$(smoke_make_workspace spin)
ORIGIN_PORT=${SPIN_SMOKE_ORIGIN_PORT:-19189}
BASE_PORT=${SPIN_SMOKE_PORT:-19180}
+POSITIVE_PORT=$((BASE_PORT + 4))
ORIGIN_PID=""
APP_PID=""
CONFIG_PUSHED=false
@@ -42,7 +43,8 @@ fi
smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT"
ORIGIN_PID=$SMOKE_ORIGIN_PID
-smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT"
+# The positive case uses the final listener in the case sequence.
+smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" "$POSITIVE_PORT"
SPIN_WORK="$WORKSPACE/spin"
mkdir -p "$SPIN_WORK"
@@ -134,6 +136,6 @@ run_case missing-proxy "$((BASE_PORT + 2))" "$SPIN_PROXY_VAR" 503 \
"resolved secret at \`publisher.proxy_secret\` must not be empty"
run_case missing-ec "$((BASE_PORT + 3))" "$SPIN_EC_VAR" 503 \
"resolved secret at \`ec.passphrase\` must not be empty"
-run_case positive "$((BASE_PORT + 4))" "" 200
+run_case positive "$POSITIVE_PORT" "" 200
echo "Spin first-success smoke passed"
diff --git a/scripts/template-cache-local-test.sh b/scripts/template-cache-local-test.sh
index 01efe9a9fd..bc683e410c 100755
--- a/scripts/template-cache-local-test.sh
+++ b/scripts/template-cache-local-test.sh
@@ -245,10 +245,10 @@ sleep 1
info "Generating stub config (mode: $MODE)"
python3 - "$REPO_ROOT/trusted-server.example.toml" "$WORK/app.toml" "$CONFIG_MODE" \
- "$ORIGIN_PORT" "$BID_PORT" <<'PYEOF'
+ "$ORIGIN_PORT" "$BID_PORT" "$TS_PORT" <<'PYEOF'
import sys
-src, out, mode, origin_port, bid_port = sys.argv[1:6]
+src, out, mode, origin_port, bid_port, ts_port = sys.argv[1:7]
s = open(src).read()
def replace_once(content, old, new, description):
@@ -263,6 +263,12 @@ s = replace_once(
f'origin_url = "http://127.0.0.1:{origin_port}"',
"publisher origin",
)
+s = replace_once(
+ s,
+ 'public_origin = "https://ads.example.com"',
+ f'public_origin = "http://127.0.0.1:{ts_port}"',
+ "publisher public origin",
+)
s = replace_once(
s,
'domain = "example.com"',
diff --git a/trusted-server.example.toml b/trusted-server.example.toml
index 99afa869dd..7b17751835 100644
--- a/trusted-server.example.toml
+++ b/trusted-server.example.toml
@@ -59,6 +59,14 @@ domain = "example.com"
cookie_domain = ".example.com"
# Upstream origin to proxy publisher content from. No trailing slash.
origin_url = "https://origin.example.com"
+# Actual browser-facing Trusted Server endpoint for rewritten creative URLs.
+# Use HTTPS in deployments, or e.g. http://localhost:8080 for direct local dev.
+# Use domain or one of its subdomains to stay within the EC cookie scope.
+# Replace this placeholder before deployment. Keep this leaf for env overlays.
+# Review the public_origin fallback and coordinated upgrade/rollback warning
+# under Publisher configuration before deployment:
+# docs/guide/configuration.md#publisher-configuration
+public_origin = "https://ads.example.com"
# Secret Store key for the HMAC secret used to sign first-party proxy URLs.
proxy_secret = "publisher_proxy_secret"
# Optional: override the outbound Host header sent to origin_url.