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.