From 0bb711f22d01a0394e3b4cb4c7553f23f17a832f Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Jul 2026 13:25:51 -0500 Subject: [PATCH 1/8] Emit absolute creative rewrite URLs --- CHANGELOG.md | 1 + crates/trusted-server-adapter-axum/src/app.rs | 2 +- .../tests/routes.rs | 1 + .../src/app.rs | 6 + .../tests/routes.rs | 1 + .../trusted-server-adapter-fastly/src/app.rs | 18 ++- crates/trusted-server-adapter-spin/src/app.rs | 8 +- .../tests/routes.rs | 38 +++-- .../trusted-server-core/src/auction/README.md | 4 +- .../src/auction/formats.rs | 23 +++ .../trusted-server-core/src/config_payload.rs | 21 +++ crates/trusted-server-core/src/creative.rs | 71 +++++++-- crates/trusted-server-core/src/proxy.rs | 143 +++++++++++++---- crates/trusted-server-core/src/settings.rs | 149 ++++++++++++++++++ .../tests/parity.rs | 26 +++ .../lib/src/integrations/creative/click.ts | 9 +- .../src/integrations/creative/first_party.ts | 41 +++++ .../lib/src/integrations/creative/index.ts | 2 + .../src/integrations/creative/proxy_sign.ts | 16 +- .../test/integrations/creative/click.test.ts | 33 +++- .../creative/click_capture.test.ts | 46 ++++++ .../integrations/creative/first_party.test.ts | 51 ++++++ .../test/integrations/creative/iframe.test.ts | 4 +- .../test/integrations/creative/image.test.ts | 4 +- .../integrations/creative/proxy_sign.test.ts | 27 +++- docs/guide/api-reference.md | 8 +- docs/guide/auction-orchestration.md | 26 +-- docs/guide/configuration.md | 15 ++ docs/guide/creative-processing.md | 16 +- docs/guide/first-party-proxy.md | 8 +- docs/guide/testing.md | 4 + trusted-server.example.toml | 2 + 32 files changed, 718 insertions(+), 106 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/creative/first_party.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/creative/click_capture.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc49c80bb..9b1bdbc962 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 +- Creative rewrite proxy, click, CSS, `srcset`, sign-response, rebuild-response, and injected creative TSJS URLs are now absolute on optional `publisher.public_origin` (falling back to `https://{publisher.domain}`). Existing root-relative signed proxy and click inputs remain accepted. - **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries. - **Breaking** — Sourcepoint browser module inclusion now requires explicit `[integrations.sourcepoint].enabled = true`; operators relying on the previous unconditional Sourcepoint module should enable the integration before upgrading. diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f43295744..b329500459 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -349,7 +349,7 @@ fn named_routes() -> [NamedRoute; 12] { }, NamedRoute { path: "/first-party/proxy-rebuild", - primary_methods: &[Method::POST], + primary_methods: &[Method::GET, Method::POST], handler: NamedRouteHandler::FirstPartyProxyRebuild, }, ] diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c4bf7d9908..73b223eb5b 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -81,6 +81,7 @@ fn all_explicit_routes_are_registered() { ("GET", "/first-party/click"), ("GET", "/first-party/sign"), ("POST", "/first-party/sign"), + ("GET", "/first-party/proxy-rebuild"), ("POST", "/first-party/proxy-rebuild"), ]; diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f68..f5d968bda6 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -526,6 +526,12 @@ fn build_router(state: &Arc) -> RouterService { handle_first_party_proxy_sign(&s.settings, &services, req).await }), ) + .get( + "/first-party/proxy-rebuild", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_first_party_proxy_rebuild(&s.settings, &services, req).await + }), + ) .post( "/first-party/proxy-rebuild", make_handler(Arc::clone(&state), |s, services, req| async move { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index df2781945d..ba2ca162ae 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -220,6 +220,7 @@ fn all_explicit_routes_are_registered() { ("GET", "/first-party/click"), ("GET", "/first-party/sign"), ("POST", "/first-party/sign"), + ("GET", "/first-party/proxy-rebuild"), ("POST", "/first-party/proxy-rebuild"), ]; diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 955ff235b2..b83c92cf31 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -32,7 +32,7 @@ //! | GET | `/first-party/click` | [`handle_first_party_click`] | //! | GET | `/first-party/sign` | [`handle_first_party_proxy_sign`] | //! | POST | `/first-party/sign` | [`handle_first_party_proxy_sign`] | -//! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | +//! | GET, POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | //! | GET | `/` and `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback | //! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | `/` and `/{*rest}` | integration proxy or publisher fallback | //! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | named paths above | publisher fallback (legacy parity for non-primary methods) | @@ -1104,7 +1104,7 @@ const NAMED_ROUTES: &[NamedRoute] = &[ }, NamedRoute { path: "/first-party/proxy-rebuild", - primary_methods: &[Method::POST], + primary_methods: &[Method::GET, Method::POST], handler: NamedRouteHandler::FirstPartyProxyRebuild, }, ]; @@ -1561,6 +1561,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/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce74c..8f6e5f7295 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -154,7 +154,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { ("/first-party/proxy", &[Method::GET]), ("/first-party/click", &[Method::GET]), ("/first-party/sign", &[Method::GET, Method::POST]), - ("/first-party/proxy-rebuild", &[Method::POST]), + ("/first-party/proxy-rebuild", &[Method::GET, Method::POST]), ] } @@ -608,7 +608,7 @@ fn build_router(state: &Arc) -> RouterService { }; let fp_sign_post_handler = fp_sign_handler.clone(); - // /first-party/proxy-rebuild + // GET + POST /first-party/proxy-rebuild — identical handler, cloned for both bindings. let s = Arc::clone(&state); let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); @@ -622,6 +622,7 @@ fn build_router(state: &Arc) -> RouterService { ) } }; + let fp_rebuild_post_handler = fp_rebuild_handler.clone(); // Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher. async fn dispatch( @@ -741,7 +742,8 @@ fn build_router(state: &Arc) -> RouterService { .get("/first-party/click", fp_click_handler) .get("/first-party/sign", fp_sign_handler) .post("/first-party/sign", fp_sign_post_handler) - .post("/first-party/proxy-rebuild", fp_rebuild_handler); + .get("/first-party/proxy-rebuild", fp_rebuild_handler) + .post("/first-party/proxy-rebuild", fp_rebuild_post_handler); for method in LEGACY_ADMIN_DENY_METHODS { builder = builder.route("/admin/keys/rotate", method.clone(), legacy_admin_deny); diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 9b96dbd70e..75377783df 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -467,6 +467,22 @@ async fn first_party_proxy_rebuild_is_routed() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn first_party_proxy_rebuild_get_is_routed() { + let router = test_router(); + let req = request_builder() + .method("GET") + .uri("/first-party/proxy-rebuild") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(router, req).await; + assert_ne!( + resp.status().as_u16(), + 404, + "GET /first-party/proxy-rebuild must be routed" + ); +} + // --------------------------------------------------------------------------- // First-party absolute-URI regression — Spin delivers a path-only request URI // (built from IncomingRequest::path_with_query), so the shared proxy/click/sign @@ -548,21 +564,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("signed href should be absolute"); + let (_, path_and_query) = authority_and_path + .split_once('/') + .expect("signed href should include a path"); + 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-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index dcc9e15064..21cfa50afa 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -248,7 +248,7 @@ The orchestrator collects all bids and creates an OpenRTB response: } ``` -Note that creative HTML is rewritten to use the first-party proxy (`/first-party/proxy`) for privacy and security. +Note that creative HTML is rewritten to use the configured browser-facing public origin, for example `https://ads.publisher.example/first-party/proxy`, for privacy and security. ## Route Registration & Endpoints @@ -262,7 +262,7 @@ The trusted-server handles several types of routes defined in `crates/trusted-se | `/first-party/proxy` | GET | `handle_first_party_proxy()` | Proxy creatives through first-party domain | 84 | | `/first-party/click` | GET | `handle_first_party_click()` | Track clicks on ads | 85 | | `/first-party/sign` | GET/POST | `handle_first_party_proxy_sign()` | Generate signed URLs for creatives | 86 | -| `/first-party/proxy-rebuild` | POST | `handle_first_party_proxy_rebuild()` | Rebuild creative HTML with new settings | 89 | +| `/first-party/proxy-rebuild` | GET/POST | `handle_first_party_proxy_rebuild()` | Rebuild signed creative click URLs | 89 | | `/static/tsjs=*` | GET | `handle_tsjs_dynamic()` | Serve tsjs library (Prebid.js alternative) | 66 | | `/.well-known/ts.jwks.json` | GET | `handle_jwks_endpoint()` | Public key distribution for request signing | 71 | | `/verify-signature` | POST | `handle_verify_signature()` | Verify signed requests | 74 | diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 441828a184..a8efa76c6b 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -932,6 +932,29 @@ mod tests { ); } + #[test] + fn convert_to_openrtb_response_uses_public_origin_for_creative_rewrites() { + let mut settings = make_settings(); + settings.publisher.public_origin = Some("https://ads.publisher.example:8443".to_string()); + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = Some( + r#"ad"# + .to_string(), + ); + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, true) + .expect("should convert rewritten creative"); + let json = response_json(response); + let adm = json["seatbid"][0]["bid"][0]["adm"] + .as_str() + .expect("should serialize creative HTML"); + assert!(adm.contains("https://ads.publisher.example:8443/first-party/proxy?")); + assert!(adm.contains("https://ads.publisher.example:8443/first-party/click?")); + assert!(adm.contains("https://ads.publisher.example:8443/static/tsjs=")); + } + #[test] fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() { let settings = make_settings(); diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index dd0b353378..0789e90478 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -78,6 +78,27 @@ mod tests { ); } + #[test] + fn legacy_blob_without_public_origin_uses_domain_fallback() { + let mut original = test_settings(); + original.publisher.public_origin = Some("https://ads.example.com:8443".to_string()); + let mut data = serde_json::to_value(&original).expect("should serialize settings to JSON"); + data["publisher"] + .as_object_mut() + .expect("should serialize publisher as an object") + .remove("public_origin"); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); + let json = serde_json::to_string(&envelope).expect("should serialize legacy envelope"); + + let reconstructed = settings_from_config_blob(&json) + .expect("should reconstruct settings without public_origin"); + assert_eq!( + reconstructed.publisher.effective_public_origin(), + format!("https://{}", original.publisher.domain), + "should fall back to the publisher domain" + ); + } + #[test] fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs index 63a6d93c9d..38aeafcb21 100644 --- a/crates/trusted-server-core/src/creative.rs +++ b/crates/trusted-server-core/src/creative.rs @@ -7,7 +7,7 @@ //! //! Key behaviors: //! - Absolute and protocol-relative URLs (http/https or `//`) are proxied to -//! `/first-party/proxy?tsurl=&&tstoken=` across these locations: +//! `{public_origin}/first-party/proxy?tsurl=&&tstoken=` across these locations: //! - ``, `data-src`, `[srcset]`, `[imagesrcset]` //! - `", + first_party_url(settings, &script_src) + ); el.prepend(&script_tag, ContentType::Html); injected.set(true); } @@ -769,7 +780,8 @@ impl StreamProcessor for CreativeCssProcessor<'_> { #[cfg(test)] mod tests { use super::{ - rewrite_creative_html, rewrite_srcset, rewrite_style_urls, sanitize_creative_html, to_abs, + build_click_url, build_proxy_url, rewrite_creative_html, rewrite_srcset, + rewrite_style_urls, sanitize_creative_html, to_abs, }; fn rewrite_srcset_attr(attr_name: &str, attr_value: &str) -> String { @@ -1306,7 +1318,7 @@ mod tests { "#; let out = rewrite_creative_html(&settings, html); - assert!(out.contains("data-src=\"/first-party/proxy?tsurl=")); + assert!(out.contains("data-src=\"https://test-publisher.com/first-party/proxy?tsurl=")); assert!(out.matches("/first-party/proxy?tsurl=").count() >= 1); // relative candidate remains assert!(out.contains("/local/img.png 1x")); @@ -1439,7 +1451,46 @@ mod tests { // 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("data-tsclick=\"https://test-publisher.com/first-party/click")); + } + + #[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"); + let click = build_click_url(&settings, "https://advertiser.example.com/landing"); + 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("origin.test-publisher.com")); + assert!(!out.contains("https://test-publisher.com/first-party")); + } + + #[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"); + assert!(proxy.starts_with("https://test-publisher.com/first-party/proxy?")); } // ── sanitize_creative_html tests ──────────────────────────────────────── diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 9e03f4dbdd..94f1c7b838 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -15,7 +15,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::{ @@ -1695,7 +1695,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. @@ -1757,13 +1757,16 @@ pub async fn handle_first_party_proxy_rebuild( } }; - let base = "https://edge.local"; // dummy origin to parse relative path - let c_url = url::Url::parse(&format!("{}{}", base, payload.tsclick)).change_context( - TrustedServerError::Proxy { - message: "invalid tsclick".to_string(), - }, - )?; - if c_url.path() != "/first-party/click" { + let qualified_tsclick = + if payload.tsclick.starts_with('/') && !payload.tsclick.starts_with("//") { + first_party_url(settings, &payload.tsclick) + } else { + payload.tsclick.clone() + }; + let c_url = url::Url::parse(&qualified_tsclick).change_context(TrustedServerError::Proxy { + message: "invalid tsclick".to_string(), + })?; + 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(), })); @@ -1771,7 +1774,7 @@ pub async fn handle_first_party_proxy_rebuild( // Validate the tstoken on the original click URL before applying any changes. // Without this, an attacker could submit an unsigned tsclick and mint valid // click redirects to arbitrary URLs. - reconstruct_and_validate_signed_target(settings, &format!("{}{}", base, payload.tsclick))?; + reconstruct_and_validate_signed_target(settings, &qualified_tsclick)?; // Extract tsurl and original params (exclude tstoken if present) let mut tsurl: Option = None; @@ -1857,7 +1860,7 @@ 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 href = first_party_url(settings, &format!("/first-party/click?{}", qs.finish())); // Compute diagnostics: added and removed let mut added: std::collections::BTreeMap = std::collections::BTreeMap::new(); @@ -2353,7 +2356,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", }); @@ -2363,7 +2368,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\""), @@ -2626,6 +2635,89 @@ 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}")] { + 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_authorities_and_paths() { + futures::executor::block_on(async { + let settings = create_test_settings(); + for tsclick in [ + "//edge.example/first-party/click?tsurl=x", + "ftp://edge.example/first-party/click?tsurl=x", + "https://edge.example/first-party/click-extra?tsurl=x", + "not a URL", + ] { + 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"); + assert!( + handle_first_party_proxy_rebuild(&settings, &noop_services(), req) + .await + .is_err(), + "should reject {tsclick:?}" + ); + } + }); + } + // Build a signed `/first-party/click` URL carrying a future `tsexp` replay // bound, returning (tsclick, tsexp_value). fn signed_click_with_tsexp(settings: &crate::settings::Settings) -> (String, String) { @@ -2729,14 +2821,10 @@ 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 + // 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( - &settings, - &format!("https://edge.example{}", first_party), - ) - .expect("reconstruct ok"); + 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)); @@ -2747,11 +2835,8 @@ mod tests { 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( - &settings, - &format!("https://edge.example{}", first_party), - ) - .expect("reconstruct ok"); + 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); @@ -2762,10 +2847,9 @@ 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 + // 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)); + let req = build_http_request(Method::GET, first_party); let err: Report = handle_first_party_proxy(&settings, &noop_services(), req) .await @@ -2803,8 +2887,7 @@ mod tests { 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 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/settings.rs b/crates/trusted-server-core/src/settings.rs index 03cc535c80..ffa7de7e98 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -35,6 +35,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))] @@ -83,6 +90,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(), @@ -125,6 +133,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, @@ -145,6 +154,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 { @@ -2339,6 +2356,52 @@ fn validate_no_trailing_slash(value: &str) -> Result<(), ValidationError> { Ok(()) } +fn validate_public_origin(value: &str) -> Result<(), ValidationError> { + 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.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(()) +} + 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"); @@ -2750,6 +2813,84 @@ 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(), + "https://test-publisher.com", + "should use the publisher domain for the compatibility fallback" + ); + 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( + r#"origin_url = "https://origin.test-publisher.com""#, + &format!( + "origin_url = \"https://origin.test-publisher.com\"\npublic_origin = \"{origin}\"" + ), + )) + .expect("should accept an HTTP(S) origin without a trailing slash"); + assert_eq!(settings.publisher.effective_public_origin(), origin); + } + + for origin in [ + "ftp://ads.example.com", + "https://user@ads.example.com", + "https://ads.example.com/path", + "https://ads.example.com?query=1", + "https://ads.example.com#fragment", + "https://ads.example.com/", + " https://ads.example.com", + "https://ads.example.com ", + "https://ads.exa\nmple.com", + ] { + let result = Settings::from_toml(&crate_test_settings_str().replace( + r#"origin_url = "https://origin.test-publisher.com""#, + &format!( + "origin_url = \"https://origin.test-publisher.com\"\npublic_origin = \"{origin}\"" + ), + )); + assert!( + result.is_err(), + "should reject invalid public origin {origin:?}" + ); + } + + for origin in [" https://ads.example.com", "https://ads.exa\nmple.com"] { + 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_round_trips_through_json() { + let settings = Settings::from_toml(&crate_test_settings_str().replace( + r#"origin_url = "https://origin.test-publisher.com""#, + "origin_url = \"https://origin.test-publisher.com\"\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 [ @@ -3851,6 +3992,7 @@ origin_host_header_overide = "www.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, @@ -3862,6 +4004,7 @@ origin_host_header_overide = "www.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, @@ -3873,6 +4016,7 @@ origin_host_header_overide = "www.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, @@ -3884,6 +4028,7 @@ origin_host_header_overide = "www.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, @@ -3895,6 +4040,7 @@ origin_host_header_overide = "www.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, @@ -3906,6 +4052,7 @@ origin_host_header_overide = "www.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, @@ -3919,6 +4066,7 @@ origin_host_header_overide = "www.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, @@ -3933,6 +4081,7 @@ origin_host_header_overide = "www.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-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index e85b1d8d1e..6348bc570f 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -431,6 +431,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/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index a505c03c56..42e29f78ca 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -4,6 +4,8 @@ import { creativeGlobal } from '../../shared/globals'; import { delay, queueTask } from '../../shared/async'; import { createMutationScheduler } from '../../shared/scheduler'; +import { resolveFirstPartyPath } from './first_party'; + type AnchorLike = HTMLAnchorElement | HTMLAreaElement; type Canon = { base: string; params: Record }; type Diff = { add: Record; del: string[] }; @@ -37,8 +39,7 @@ function parseQuery(qs: string): Record { function canonFromFirstPartyClick(url: string): Canon | null { try { const u = new URL(url, location.href); - if (!(u.pathname === '/first-party/click' || u.pathname.startsWith('/first-party/click'))) - return null; + if (u.pathname !== '/first-party/click') return null; const p = parseQuery(u.search); const tsurl = p['tsurl']; if (!tsurl) return null; @@ -141,7 +142,7 @@ function buildProxyRebuildUrl(tsClickStr: string, diff: Diff): string { if (diff.del.length > 0) { params.set('del', JSON.stringify(diff.del)); } - return `/first-party/proxy-rebuild?${params.toString()}`; + return resolveFirstPartyPath(`/first-party/proxy-rebuild?${params.toString()}`); } // Call the proxy-rebuild endpoint so the edge can re-sign mutated click params. @@ -169,7 +170,7 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom if (delKeys.length > 0) payload.del = delKeys; try { - const resp = await fetch('/first-party/proxy-rebuild', { + const resp = await fetch(resolveFirstPartyPath('/first-party/proxy-rebuild'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), diff --git a/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts b/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts new file mode 100644 index 0000000000..33c64fe7c0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts @@ -0,0 +1,41 @@ +// Immutable browser-facing first-party endpoint resolver for the creative IIFE. +// Capture the injected classic script synchronously during module evaluation so +// later DOM mutations cannot redirect dynamic sign or rebuild requests. +let capturedOrigin: string | null = null; + +try { + const source = document.currentScript?.getAttribute('src') || document.currentScript?.src; + if (source) capturedOrigin = new URL(source, location.href).origin; +} catch { + capturedOrigin = null; +} + +export function firstPartyOrigin(): string | null { + if (capturedOrigin) return capturedOrigin; + try { + return new URL(location.href).origin; + } catch { + return null; + } +} + +export function resolveFirstPartyPath(path: string): string { + const origin = firstPartyOrigin(); + if (!origin || !path.startsWith('/')) return path; + try { + return new URL(path, origin).toString(); + } catch { + return path; + } +} + +export function isFirstPartyProxyUrl(value: string): boolean { + const origin = firstPartyOrigin(); + if (!origin) return false; + try { + const url = new URL(value, origin); + return url.origin === origin && url.pathname === '/first-party/proxy'; + } catch { + return false; + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/creative/index.ts b/crates/trusted-server-js/lib/src/integrations/creative/index.ts index 395553562e..f1e21f135b 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/index.ts @@ -3,6 +3,8 @@ import { log } from '../../core/log'; import type { TsCreativeConfig, CreativeWindow, TsCreativeApi } from '../../shared/globals'; import { creativeGlobal, resolveWindow } from '../../shared/globals'; +import './first_party'; + import { installClickGuard } from './click'; import { installDynamicImageProxy } from './image'; import { installDynamicIframeProxy } from './iframe'; diff --git a/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts b/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts index b18d7d1a3c..9daa8e28e7 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts @@ -1,18 +1,15 @@ import { log } from '../../core/log'; -const PROXY_PREFIX = '/first-party/proxy'; +import { isFirstPartyProxyUrl, resolveFirstPartyPath } from './first_party'; export function shouldProxyExternalUrl(raw: string): boolean { const value = String(raw || '').trim(); if (!value) return false; if (/^(data:|javascript:|blob:|about:)/i.test(value)) return false; - if (value.startsWith(PROXY_PREFIX)) return false; + if (isFirstPartyProxyUrl(value)) return false; try { const url = new URL(value, location.href); - if (url.origin === location.origin) { - if (url.pathname.startsWith(PROXY_PREFIX)) return false; - return false; - } + if (url.origin === location.origin) return false; return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; @@ -28,12 +25,7 @@ export async function signProxyUrl(raw: string): Promise { return null; } - let endpoint = '/first-party/sign'; - try { - endpoint = new URL('/first-party/sign', location.href).toString(); - } catch { - /* fall back to relative path */ - } + const endpoint = resolveFirstPartyPath('/first-party/sign'); try { const resp = await fetch(endpoint, { diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index 05dcd0e02e..7cbfedb9bf 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts @@ -32,11 +32,40 @@ describe('creative/click.ts', () => { await vi.runAllTimersAsync(); const finalHref = anchor.getAttribute('href') ?? ''; - expect(finalHref.startsWith('/first-party/proxy-rebuild?')).toBe(true); + expect(finalHref.startsWith('http://localhost:3000/first-party/proxy-rebuild?')).toBe(true); expect(finalHref).toContain('add=%7B%22bar%22%3A%222%22%7D'); expect(finalHref).toContain('del=%5B%22foo%22%5D'); }); + it('targets the captured script origin for rebuild requests and fallbacks', async () => { + vi.useFakeTimers(); + const descriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); + const script = Object.assign(document.createElement('script'), { + src: 'https://ads.example.com:8443/static/tsjs=tsjs-unified.min.js?v=hash', + }); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + const fetchMock = vi.fn().mockRejectedValue(new Error('network')); + global.fetch = fetchMock as unknown as typeof fetch; + + try { + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', FIRST_PARTY_CLICK); + document.body.appendChild(anchor); + await importCreativeModule(); + anchor.setAttribute('href', MUTATED_CLICK); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(fetchMock.mock.calls.map((call) => call[0])).toContain( + 'https://ads.example.com:8443/first-party/proxy-rebuild' + ); + } finally { + if (descriptor) Object.defineProperty(document, 'currentScript', descriptor); + else Reflect.deleteProperty(document, 'currentScript'); + } + }); + it('updates anchors using proxy rebuild response payload', async () => { vi.useFakeTimers(); @@ -60,7 +89,7 @@ describe('creative/click.ts', () => { expect(fetchMock).toHaveBeenCalled(); const call = fetchMock.mock.calls[0]; - expect(call[0]).toBe('/first-party/proxy-rebuild'); + expect(call[0]).toBe('http://localhost:3000/first-party/proxy-rebuild'); const payload = JSON.parse(call[1]?.body as string); expect(payload).toEqual({ tsclick: FIRST_PARTY_CLICK, diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click_capture.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click_capture.test.ts new file mode 100644 index 0000000000..56654f2305 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/click_capture.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FIRST_PARTY_CLICK, MUTATED_CLICK, importCreativeModule } from './helpers'; + +const originalFetch = global.fetch; +const currentScriptDescriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); + +beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + vi.useFakeTimers(); +}); + +afterEach(() => { + global.fetch = originalFetch; + vi.useRealTimers(); + if (currentScriptDescriptor) { + Object.defineProperty(document, 'currentScript', currentScriptDescriptor); + } else { + Reflect.deleteProperty(document, 'currentScript'); + } +}); + +describe('creative/click.ts captured first-party fallback', () => { + it('installs an absolute GET fallback when fetch is unavailable', async () => { + const script = Object.assign(document.createElement('script'), { + src: 'https://ads.example.com:8443/static/tsjs=tsjs-unified.min.js?v=hash', + }); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + global.fetch = undefined as unknown as typeof fetch; + + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', FIRST_PARTY_CLICK); + document.body.appendChild(anchor); + + await importCreativeModule(); + anchor.setAttribute('href', MUTATED_CLICK); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + const fallback = anchor.getAttribute('href') ?? ''; + expect(fallback).toMatch(/^https:\/\/ads\.example\.com:8443\/first-party\/proxy-rebuild\?/); + expect(anchor.getAttribute('data-tsclick')).toBe(fallback); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts new file mode 100644 index 0000000000..dd0abf298a --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const currentScriptDescriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); + +function setCurrentScript(src?: string): void { + const script = src ? Object.assign(document.createElement('script'), { src }) : null; + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: script, + }); +} + +afterEach(() => { + vi.resetModules(); + if (currentScriptDescriptor) { + Object.defineProperty(document, 'currentScript', currentScriptDescriptor); + } else { + Reflect.deleteProperty(document, 'currentScript'); + } +}); + +describe('creative/first_party.ts', () => { + it('captures the injected classic script origin and resolves endpoints against it', async () => { + setCurrentScript('https://ads.example.com:8443/static/tsjs=tsjs-unified.min.js?v=hash'); + const { firstPartyOrigin, resolveFirstPartyPath, isFirstPartyProxyUrl } = + await import('../../../src/integrations/creative/first_party'); + + expect(firstPartyOrigin()).toBe('https://ads.example.com:8443'); + expect(resolveFirstPartyPath('/first-party/sign')).toBe( + 'https://ads.example.com:8443/first-party/sign' + ); + expect(isFirstPartyProxyUrl('https://ads.example.com:8443/first-party/proxy?token=1')).toBe( + true + ); + expect(isFirstPartyProxyUrl('https://ads.example.com:8443/first-party/proxy-extra')).toBe( + false + ); + expect(isFirstPartyProxyUrl('https://foreign.example/first-party/proxy')).toBe(false); + }); + + it('falls back to the document origin when currentScript is unavailable', async () => { + setCurrentScript(); + const { firstPartyOrigin, resolveFirstPartyPath } = + await import('../../../src/integrations/creative/first_party'); + + expect(firstPartyOrigin()).toBe(location.origin); + expect(resolveFirstPartyPath('/first-party/proxy-rebuild')).toBe( + new URL('/first-party/proxy-rebuild', location.href).toString() + ); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts index 48133fb723..101130f525 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts @@ -16,7 +16,7 @@ describe('creative/iframe.ts', () => { it('proxies iframe src via signer endpoint', async () => { const signed = - '/first-party/proxy?tsurl=https%3A%2F%2Fframe.example%2Fwidget.html&tstoken=iframe&tsexp=1'; + 'https://ads.example.com/first-party/proxy?tsurl=https%3A%2F%2Fframe.example%2Fwidget.html&tstoken=iframe&tsexp=1'; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ href: signed }), @@ -33,7 +33,7 @@ describe('creative/iframe.ts', () => { expect.stringContaining('/first-party/sign'), expect.objectContaining({ method: 'POST' }) ); - expect(iframe.src).toContain('/first-party/proxy?'); + expect(iframe.src).toContain('https://ads.example.com/first-party/proxy?'); expect(iframe.src).toContain('tsexp='); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts index 44105571d8..d9cfe11a37 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts @@ -16,7 +16,7 @@ describe('creative/image.ts', () => { it('proxies image src via signer endpoint', async () => { const signed = - '/first-party/proxy?tsurl=https%3A%2F%2Fimg.example%2Fpixel.gif&tstoken=new&tsexp=1'; + 'https://ads.example.com/first-party/proxy?tsurl=https%3A%2F%2Fimg.example%2Fpixel.gif&tstoken=new&tsexp=1'; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ href: signed }), @@ -33,7 +33,7 @@ describe('creative/image.ts', () => { expect.stringContaining('/first-party/sign'), expect.objectContaining({ method: 'POST' }) ); - expect(img.src).toContain('/first-party/proxy?'); + expect(img.src).toContain('https://ads.example.com/first-party/proxy?'); expect(img.src).toContain('tsexp='); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts index 41c86a8732..6e44b265c7 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts @@ -17,16 +17,37 @@ describe('creative/proxy_sign.ts', () => { expect(shouldProxyExternalUrl('http://cdn.example/pixel.gif')).toBe(true); }); - it('rejects data, javascript, and same-origin URLs', () => { + it('rejects data, javascript, same-origin URLs, and only the exact trusted proxy path', () => { expect(shouldProxyExternalUrl('data:image/png;base64,AAAA')).toBe(false); expect(shouldProxyExternalUrl('javascript:alert(1)')).toBe(false); expect(shouldProxyExternalUrl('/first-party/proxy?foo=1')).toBe(false); expect(shouldProxyExternalUrl(`${location.origin}/first-party/proxy?foo=1`)).toBe(false); + expect(shouldProxyExternalUrl('https://foreign.example/first-party/proxy?foo=1')).toBe(true); + }); + + it('trusts only the captured origin and exact proxy endpoint', async () => { + const descriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); + const script = Object.assign(document.createElement('script'), { + src: 'https://ads.example.com/static/tsjs=tsjs-unified.min.js?v=hash', + }); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + vi.resetModules(); + + try { + const { shouldProxyExternalUrl: shouldProxy } = + await import('../../../src/integrations/creative/proxy_sign'); + expect(shouldProxy('https://ads.example.com/first-party/proxy?token=1')).toBe(false); + expect(shouldProxy('https://ads.example.com/first-party/proxy-extra?token=1')).toBe(true); + expect(shouldProxy('https://foreign.example/first-party/proxy?token=1')).toBe(true); + } finally { + if (descriptor) Object.defineProperty(document, 'currentScript', descriptor); + else Reflect.deleteProperty(document, 'currentScript'); + } }); it('posts to /first-party/sign and returns signed href', async () => { const signed = - '/first-party/proxy?tsurl=https%3A%2F%2Fcdn.example%2Fasset.js&tstoken=tok&tsexp=1'; + 'https://ads.example.com/first-party/proxy?tsurl=https%3A%2F%2Fcdn.example%2Fasset.js&tstoken=tok&tsexp=1'; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ href: signed }), @@ -36,7 +57,7 @@ describe('creative/proxy_sign.ts', () => { const result = await signProxyUrl('https://cdn.example/asset.js?cb=1'); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/first-party/sign'), + new URL('/first-party/sign', location.href).toString(), expect.objectContaining({ method: 'POST', headers: { 'content-type': 'application/json' }, diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 9195138a4b..cd6af3eba7 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -342,7 +342,8 @@ curl -X POST https://edge.example.com/first-party/sign \ ```json { - "signed_url": "https://edge.example.com/first-party/proxy?tsurl=https://external.com/pixel.gif&tstoken=abc123..." + "href": "https://ads.publisher.example/first-party/proxy?tsurl=https://external.com/pixel.gif&tsexp=123&tstoken=abc123...", + "base": "https://external.com/pixel.gif" } ``` @@ -354,7 +355,7 @@ curl -X POST https://edge.example.com/first-party/sign \ --- -### POST /first-party/proxy-rebuild +### GET/POST /first-party/proxy-rebuild URL mutation recovery endpoint. Rebuilds signed proxy URL after creative JavaScript modifies query parameters. @@ -374,7 +375,8 @@ URL mutation recovery endpoint. Rebuilds signed proxy URL after creative JavaScr ```json { - "url": "https://edge.example.com/first-party/click?tsurl=https://advertiser.com&campaign=123&utm_source=banner&tstoken=new..." + "href": "https://ads.publisher.example/first-party/click?tsurl=https://advertiser.com&campaign=123&utm_source=banner&tstoken=new...", + "base": "https://advertiser.com" } ``` diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index d75958812c..06addb9263 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -150,7 +150,7 @@ sequenceDiagram Orch->>Orch: Transform to OpenRTB response
Generate iframe creatives
Rewrite creative URLs
Add orchestrator metadata Orch-->>TS: OpenRTB BidResponse - Note right of Orch: { "id": "auction-response",
"seatbid": [{ "seat": "amazon-aps",
"bid": [{ "price": 2.50,
"adm": "", + "adm": "", "w": 728, "h": 90 } @@ -557,16 +557,16 @@ Winning creatives are processed through a streaming HTML rewriter (`lol_html`) b **Elements rewritten:** -| Element | Attributes | Target | -| -------------------------------- | --------------------------- | ------------------------------ | -| `` | `src`, `data-src`, `srcset` | `/first-party/proxy?tsurl=...` | -| `
AdExcludedMail"#; + const MATRIX_DEFAULT_AUCTION_OUTPUT: &str = r#"
AdExcludedMail"#; const MATRIX_DEFAULT_INLINE_OUTPUT: &str = r#"
AdExcludedMail"#; #[test] @@ -2044,15 +2044,24 @@ 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), + ); 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" ); } } @@ -3786,7 +3795,10 @@ b{background:url(\"https://cdn.example/c.png\")}"; "#; let out = rewrite_creative_html(&settings, html); - assert!(out.contains("data-src=\"https://test-publisher.com/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")); @@ -4226,7 +4238,10 @@ 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=\"https://test-publisher.com/first-party/click")); + assert!(out.contains(&format!( + "data-tsclick=\"{}/first-party/click", + settings.publisher.effective_public_origin() + ))); } #[test] @@ -4257,8 +4272,11 @@ b{background:url(\"https://cdn.example/c.png\")}"; "should qualify resource, srcset, and CSS rewrites" ); assert!(out.contains("https://ads.publisher.example:8443/static/tsjs=")); - assert!(!out.contains("origin.test-publisher.com")); - assert!(!out.contains("https://test-publisher.com/first-party")); + 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!( @@ -4274,7 +4292,10 @@ b{background:url(\"https://cdn.example/c.png\")}"; 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", ""); - assert!(proxy.starts_with("https://test-publisher.com/first-party/proxy?")); + assert!(proxy.starts_with(&format!( + "https://{}/first-party/proxy?", + settings.publisher.domain + ))); } // ── sanitize_creative_html tests ──────────────────────────────────────── diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 943d55f7fe..12702ae7ff 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1928,15 +1928,17 @@ pub async fn handle_first_party_proxy_rebuild( rebuild_request_from_pairs(url::form_urlencoded::parse(req_query.as_bytes()))? }; - let qualified_tsclick = - if payload.tsclick.starts_with('/') && !payload.tsclick.starts_with("//") { - first_party_url(settings, &payload.tsclick) - } else { - payload.tsclick.clone() - }; - let c_url = url::Url::parse(&qualified_tsclick).change_context(TrustedServerError::Proxy { - message: "invalid tsclick".to_string(), - })?; + // Input parsing is independent of the configured output origin. Accept old + // root-relative clicks and absolute clicks without changing their signatures. + if payload.tsclick.starts_with("//") { + return Err(Report::new(TrustedServerError::Proxy { + message: "invalid tsclick".to_string(), + })); + } + let c_url = + parse_request_target(&payload.tsclick).change_context(TrustedServerError::Proxy { + message: "invalid tsclick".to_string(), + })?; 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(), @@ -1945,7 +1947,7 @@ pub async fn handle_first_party_proxy_rebuild( // Validate the tstoken on the original click URL before applying any changes. // Without this, an attacker could submit an unsigned tsclick and mint valid // click redirects to arbitrary URLs. - reconstruct_and_validate_signed_target(settings, &qualified_tsclick)?; + reconstruct_and_validate_signed_target(settings, c_url.as_str())?; // Extract tsurl and original params (exclude tstoken if present) let mut tsurl: Option = None; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index e9c5fe0d71..89edab5768 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -153,6 +153,17 @@ 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).change_context(TrustedServerError::Configuration { + message: "Invalid publisher.public_origin".to_string(), + })?; + *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", @@ -3251,8 +3262,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(); @@ -3799,6 +3812,10 @@ fn validate_no_trailing_slash(value: &str) -> Result<(), ValidationError> { } 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); @@ -3841,7 +3858,7 @@ fn validate_public_origin(value: &str) -> Result<(), ValidationError> { return Err(ValidationError::new("public_origin_has_fragment")); } - Ok(()) + Ok(parsed) } fn validate_host_header_override(value: &str) -> Result<(), ValidationError> { @@ -5835,8 +5852,8 @@ mod tests { .expect("should parse settings without public origin"); assert_eq!( fallback.publisher.effective_public_origin(), - "https://test-publisher.com", - "should use the publisher domain for the compatibility fallback" + 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!( @@ -5846,15 +5863,14 @@ mod tests { for origin in ["https://ads.example.com", "http://localhost:8080"] { let settings = Settings::from_toml(&crate_test_settings_str().replace( - r#"origin_url = "https://origin.test-publisher.com""#, - &format!( - "origin_url = \"https://origin.test-publisher.com\"\npublic_origin = \"{origin}\"" - ), + "[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", @@ -5864,13 +5880,11 @@ mod tests { "https://ads.example.com/", " https://ads.example.com", "https://ads.example.com ", - "https://ads.exa\nmple.com", + with_newline.as_str(), ] { let result = Settings::from_toml(&crate_test_settings_str().replace( - r#"origin_url = "https://origin.test-publisher.com""#, - &format!( - "origin_url = \"https://origin.test-publisher.com\"\npublic_origin = \"{origin}\"" - ), + "[publisher]", + &format!("[publisher]\npublic_origin = \"{origin}\""), )); assert!( result.is_err(), @@ -5878,7 +5892,7 @@ mod tests { ); } - for origin in [" https://ads.example.com", "https://ads.exa\nmple.com"] { + 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!( @@ -5888,11 +5902,63 @@ mod tests { } } + #[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", + "", + ); + 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( - r#"origin_url = "https://origin.test-publisher.com""#, - "origin_url = \"https://origin.test-publisher.com\"\npublic_origin = \"https://ads.example.com:8443\"", + "[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"); diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index 40b85db05d..417be11a2b 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -362,19 +362,6 @@ function navigate(a: AnchorLike, url: string, isMiddle: boolean): void { // compare against — is only updated when the value is itself a signed // /first-party/click URL. Writing the GET proxy-rebuild fallback there would // make every later canonicalization fail and lose subsequent mutations. -// Root-relative form of a first-party URL — the shape the server-side rewriter -// emits and the shape `data-tsclick` must keep. `href` is absolutized so it -// resolves inside the srcdoc frame, but the canonical attribute has to stay in -// the server's format: it is echoed back as the rebuild payload's `tsclick`. -function canonicalClickValue(resolved: string): string { - try { - const url = new URL(resolved, TRUSTED_BASE_URL); - return `${url.pathname}${url.search}`; - } catch { - return resolved; - } -} - function persistRebuiltClick(anchor: AnchorLike, finalUrl: string): void { // Persist the validated, absolutized URL — never the raw input. Beyond // enforcing the http(s) allowlist, an absolute URL keeps the anchor's @@ -395,7 +382,10 @@ function persistRebuiltClick(anchor: AnchorLike, finalUrl: string): void { try { const el = anchor as Element; if (canonFromFirstPartyClick(resolved)) { - el.setAttribute('data-tsclick', canonicalClickValue(resolved)); + // The server accepts absolute and legacy relative clicks. Keep the + // rebuilt authority so later navigation cannot switch to the document + // or stamped origin when it differs from the public endpoint. + el.setAttribute('data-tsclick', resolved); } el.setAttribute('href', resolved); } catch (err) { diff --git a/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts b/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts index 33c64fe7c0..76b5ad5597 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts @@ -1,22 +1,23 @@ -// Immutable browser-facing first-party endpoint resolver for the creative IIFE. -// Capture the injected classic script synchronously during module evaluation so -// later DOM mutations cannot redirect dynamic sign or rebuild requests. -let capturedOrigin: string | null = null; +import { normalizeTrustedOrigin, TRUSTED_BASE_URL } from '../../shared/origin'; -try { - const source = document.currentScript?.getAttribute('src') || document.currentScript?.src; - if (source) capturedOrigin = new URL(source, location.href).origin; -} catch { - capturedOrigin = null; -} - -export function firstPartyOrigin(): string | null { - if (capturedOrigin) return capturedOrigin; +// Capture the injected classic script synchronously so later DOM mutations +// cannot redirect endpoints. Its public origin can differ from both the +// document and the rendering parent's stamp. Only when it is unavailable do +// we fall back to the pinned stamp/document/baseURI resolution used by legacy +// relative clicks. An opaque or otherwise unusable base supplies no origin. +const endpointOrigin: string | null = (() => { try { - return new URL(location.href).origin; + const source = document.currentScript?.getAttribute('src') || document.currentScript?.src; + const origin = source && normalizeTrustedOrigin(new URL(source, location.href).origin); + if (origin) return origin; } catch { - return null; + // Fall back to the pinned document origin below. } + return normalizeTrustedOrigin(TRUSTED_BASE_URL) || null; +})(); + +export function firstPartyOrigin(): string | null { + return endpointOrigin; } export function resolveFirstPartyPath(path: string): string { diff --git a/crates/trusted-server-js/lib/src/integrations/creative/index.ts b/crates/trusted-server-js/lib/src/integrations/creative/index.ts index f1e21f135b..395553562e 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/index.ts @@ -3,8 +3,6 @@ import { log } from '../../core/log'; import type { TsCreativeConfig, CreativeWindow, TsCreativeApi } from '../../shared/globals'; import { creativeGlobal, resolveWindow } from '../../shared/globals'; -import './first_party'; - import { installClickGuard } from './click'; import { installDynamicImageProxy } from './image'; import { installDynamicIframeProxy } from './iframe'; diff --git a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs index 127b122def..2ef3c6ef2a 100644 --- a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs @@ -43,9 +43,12 @@ function createPage({ logLevel, debug = false, storedDebug = false, + origin = 'https://publisher.example.com', + scriptSrc, + stampOrigin, } = {}) { dom = new JSDOM('
', { - url: `https://publisher.example.com/${debug ? '?tsdebug=1' : ''}`, + url: `${origin}/${debug ? '?tsdebug=1' : ''}`, runScripts: 'outside-only', }); const { window } = dom; @@ -69,6 +72,12 @@ function createPage({ if (logLevel) { window.tsjs = { que: [() => window.tsjs.setConfig({ logLevel })] }; } + if (stampOrigin) window.__tsCreativeOrigin = stampOrigin; + if (scriptSrc) { + const script = window.document.createElement('script'); + script.src = scriptSrc; + Object.defineProperty(window.document, 'currentScript', { configurable: true, value: script }); + } // Match bundle.rs: core first and independent IIFEs joined into one script. window.eval(modules.map((name) => bundles.get(name)).join(';\n')); return { window, info, debugLog }; @@ -101,6 +110,68 @@ function requestContext(window) { } describe('shared state across production IIFEs', () => { + it('keeps the captured creative endpoint and repaired click authority across independent IIFEs', async () => { + const endpoint = 'https://ads.example.com:8443'; + const original = `${endpoint}/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Flanding&foo=1&tstoken=original`; + const rebuilt = `${endpoint}/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Flanding&bar=2&tstoken=rebuilt`; + const rebuiltAgain = rebuilt + .replace('bar=2', 'baz=3') + .replace('tstoken=rebuilt', 'tstoken=again'); + const { window } = createPage({ + modules: ['core', 'creative'], + origin: 'https://foreign.example', + scriptSrc: `${endpoint}/static/tsjs=tsjs-unified.min.js?v=hash`, + stampOrigin: 'https://publisher.example', + }); + Object.defineProperty(window.document, 'currentScript', { configurable: true, value: null }); + dom.reconfigure({ url: 'https://later-foreign.example/creative' }); + window.__tsCreativeOrigin = 'https://later-publisher.example'; + window.fetch = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ href: rebuilt }) }) + .mockResolvedValue({ ok: true, json: async () => ({ href: rebuiltAgain }) }); + window.open = vi.fn(); + const anchor = window.document.createElement('a'); + anchor.setAttribute('data-tsclick', original); + anchor.setAttribute('href', original); + anchor.target = '_blank'; + window.document.body.appendChild(anchor); + + anchor.setAttribute('href', 'https://example.com/landing?bar=2'); + await vi.waitFor(() => { + expect(anchor.getAttribute('href')).toBe(rebuilt); + expect(anchor.getAttribute('data-tsclick')).toBe(rebuilt); + }); + expect(window.fetch).toHaveBeenCalledTimes(1); + expect(window.fetch.mock.calls[0][0]).toBe(`${endpoint}/first-party/proxy-rebuild`); + expect(JSON.parse(window.fetch.mock.calls[0][1].body)).toEqual({ + tsclick: original, + add: { bar: '2' }, + del: ['foo'], + }); + anchor.click(); + await vi.waitFor(() => + expect(window.open).toHaveBeenLastCalledWith(rebuilt, '_blank', 'noopener,noreferrer') + ); + expect(window.fetch).toHaveBeenCalledTimes(1); + + anchor.setAttribute('href', 'https://example.com/landing?baz=3'); + await vi.waitFor(() => expect(anchor.getAttribute('data-tsclick')).toBe(rebuiltAgain)); + expect(window.fetch).toHaveBeenCalledTimes(2); + expect(window.fetch.mock.calls[1][0]).toBe(`${endpoint}/first-party/proxy-rebuild`); + expect(JSON.parse(window.fetch.mock.calls[1][1].body)).toEqual({ + tsclick: rebuilt, + add: { baz: '3' }, + del: ['bar'], + }); + anchor.click(); + await vi.waitFor(() => + expect(window.open).toHaveBeenLastCalledWith(rebuiltAgain, '_blank', 'noopener,noreferrer') + ); + expect(anchor.getAttribute('href')).toBe(rebuiltAgain); + expect(window.fetch).toHaveBeenCalledTimes(2); + }); + it('sends Permutive context from an integration bundle through core requestAds', () => { const { window } = createPage(); expect(requestContext(window)).toEqual({ permutive_segments: ['111', '222'] }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index 5f042bf3de..3873dfbcfc 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts @@ -103,15 +103,13 @@ describe('creative/click.ts', () => { }); expect(anchor.getAttribute('href')).toBe(absolute(PROXY_RESPONSE)); - // data-tsclick keeps the server's root-relative shape: it is echoed back as - // the rebuild payload's `tsclick`, which the server parses as a click path. - expect(anchor.getAttribute('data-tsclick')).toBe(PROXY_RESPONSE); + // Legacy relative responses still work, but persistence keeps the resolved + // authority for later navigation and rebuilds. + expect(anchor.getAttribute('data-tsclick')).toBe(absolute(PROXY_RESPONSE)); }); - it('sends a root-relative tsclick on a second rebuild after a successful one', async () => { - // Regression: persisting an absolute canonical click made the next rebuild - // POST a value the server rejects as an invalid click path, so the second - // mutation was silently lost on non-opaque consumers. + it('sends the persisted absolute tsclick on a second rebuild after a legacy response', async () => { + // The server accepts both absolute and legacy root-relative signed clicks. vi.useFakeTimers(); const fetchMock = vi.fn().mockResolvedValue({ @@ -131,7 +129,7 @@ describe('creative/click.ts', () => { await Promise.resolve(); await vi.runAllTimersAsync(); - expect(anchor.getAttribute('data-tsclick')).toBe(PROXY_RESPONSE); + expect(anchor.getAttribute('data-tsclick')).toBe(absolute(PROXY_RESPONSE)); // A second mutation now diffs against the rebuilt canonical click. anchor.setAttribute('href', 'https://example.com/landing?baz=3'); @@ -142,12 +140,8 @@ describe('creative/click.ts', () => { const payloads = fetchMock.mock.calls.map( (call) => JSON.parse(call[1]?.body as string) as { tsclick: string } ); - // Every payload must carry the server's root-relative click form: an - // absolute one is rejected as an invalid click path. - for (const payload of payloads) { - expect(payload.tsclick.startsWith('/first-party/click?')).toBe(true); - } - expect(payloads.some((payload) => payload.tsclick === PROXY_RESPONSE)).toBe(true); + expect(payloads[0].tsclick).toBe(FIRST_PARTY_CLICK); + expect(payloads.some((payload) => payload.tsclick === absolute(PROXY_RESPONSE))).toBe(true); }); it('skips the doomed POST and uses the GET fallback in an opaque origin', async () => { diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click_capture.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click_capture.test.ts index 3732ad3448..fe6308382e 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click_capture.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click_capture.test.ts @@ -1,46 +1,193 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FIRST_PARTY_CLICK, MUTATED_CLICK, importCreativeModule } from './helpers'; +import { FIRST_PARTY_CLICK, MUTATED_CLICK, PROXY_RESPONSE, importCreativeModule } from './helpers'; const originalFetch = global.fetch; -const currentScriptDescriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); +const stampDescriptor = Object.getOwnPropertyDescriptor(window, '__tsCreativeOrigin'); +const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); +const ENDPOINT_ORIGIN = 'https://ads.example.com:8443'; +const STAMP_ORIGIN = 'https://publisher.example'; + +function setCurrentScript(): void { + const script = Object.assign(document.createElement('script'), { + src: `${ENDPOINT_ORIGIN}/static/tsjs=tsjs-unified.min.js?v=hash`, + }); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); +} beforeEach(() => { vi.resetModules(); - document.body.innerHTML = ''; + vi.stubGlobal('document', document.implementation.createHTMLDocument('creative')); + vi.stubGlobal('location', { + href: 'https://foreign.example/creative', + origin: 'https://foreign.example', + }); + Object.defineProperty(window, '__tsCreativeOrigin', { configurable: true, value: STAMP_ORIGIN }); vi.useFakeTimers(); }); afterEach(() => { global.fetch = originalFetch; vi.useRealTimers(); - if (currentScriptDescriptor) { - Object.defineProperty(document, 'currentScript', currentScriptDescriptor); - } else { - Reflect.deleteProperty(document, 'currentScript'); - } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (stampDescriptor) Object.defineProperty(window, '__tsCreativeOrigin', stampDescriptor); + else Reflect.deleteProperty(window, '__tsCreativeOrigin'); + if (originDescriptor) Object.defineProperty(window, 'origin', originDescriptor); + else Reflect.deleteProperty(window, 'origin'); }); describe('creative/click.ts captured first-party fallback', () => { - it('installs an absolute GET fallback when fetch is unavailable', async () => { - const script = Object.assign(document.createElement('script'), { - src: 'https://ads.example.com:8443/static/tsjs=tsjs-unified.min.js?v=hash', + it('keeps rebuilt authority through repeated repairs and later navigation', async () => { + setCurrentScript(); + const rebuilt = `${ENDPOINT_ORIGIN}${PROXY_RESPONSE}`; + const rebuiltAgain = rebuilt.replace('bar=2', 'baz=3'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ href: rebuilt }) }) + .mockResolvedValue({ ok: true, json: async () => ({ href: rebuiltAgain }) }); + global.fetch = fetchMock as unknown as typeof fetch; + const openMock = vi.spyOn(window, 'open').mockImplementation(() => null); + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', `${ENDPOINT_ORIGIN}${FIRST_PARTY_CLICK}`); + anchor.setAttribute('href', `${ENDPOINT_ORIGIN}${FIRST_PARTY_CLICK}`); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + await importCreativeModule(); + await vi.runAllTimersAsync(); + + anchor.setAttribute('href', MUTATED_CLICK); + await vi.runAllTimersAsync(); + + expect(anchor.getAttribute('href')).toBe(rebuilt); + expect(anchor.getAttribute('data-tsclick')).toBe(rebuilt); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe(`${ENDPOINT_ORIGIN}/first-party/proxy-rebuild`); + anchor.click(); + await vi.runAllTimersAsync(); + expect(openMock).toHaveBeenLastCalledWith(rebuilt, '_blank', 'noopener,noreferrer'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + anchor.setAttribute('href', 'https://example.com/landing?baz=3'); + await vi.runAllTimersAsync(); + expect(anchor.getAttribute('data-tsclick')).toBe(rebuiltAgain); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ + tsclick: rebuilt, + add: { baz: '3' }, + del: ['bar'], }); - Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); - global.fetch = undefined as unknown as typeof fetch; + anchor.click(); + await vi.runAllTimersAsync(); + expect(openMock).toHaveBeenLastCalledWith(rebuiltAgain, '_blank', 'noopener,noreferrer'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it.each([true, false])( + 'preserves stamped legacy relative navigation with script=%s', + async (script) => { + if (script) setCurrentScript(); + const openMock = vi.spyOn(window, 'open').mockImplementation(() => null); + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', FIRST_PARTY_CLICK); + anchor.target = '_blank'; + document.body.appendChild(anchor); + await importCreativeModule(); + await vi.runAllTimersAsync(); + + anchor.click(); + await vi.runAllTimersAsync(); + expect(openMock).toHaveBeenLastCalledWith( + `${STAMP_ORIGIN}${FIRST_PARTY_CLICK}`, + '_blank', + 'noopener,noreferrer' + ); + expect(anchor.getAttribute('data-tsclick')).toBe(FIRST_PARTY_CLICK); + } + ); + + it.each([ + { script: true, opaque: false }, + { script: true, opaque: true }, + { script: false, opaque: true }, + ])( + 'keeps repeated fallback repairs and navigation on the endpoint: %j', + async ({ script, opaque }) => { + if (script) setCurrentScript(); + const origin = script ? ENDPOINT_ORIGIN : STAMP_ORIGIN; + const fetchMock = vi.fn(); + global.fetch = opaque + ? (fetchMock as unknown as typeof fetch) + : (undefined as unknown as typeof fetch); + if (opaque) { + vi.stubGlobal('location', { href: 'about:srcdoc', origin: 'null' }); + Object.defineProperty(window, 'origin', { configurable: true, value: 'null' }); + } + const openMock = vi.spyOn(window, 'open').mockImplementation(() => null); + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', FIRST_PARTY_CLICK); + anchor.target = '_blank'; + document.body.appendChild(anchor); + await importCreativeModule(); + await vi.runAllTimersAsync(); + for (const [mutation, param] of [ + [MUTATED_CLICK, 'bar'], + ['https://example.com/landing?baz=3', 'baz'], + ]) { + anchor.setAttribute('href', mutation); + await vi.runAllTimersAsync(); + const fallback = new URL(anchor.getAttribute('href') ?? ''); + expect(fallback.origin).toBe(origin); + expect(fallback.pathname).toBe('/first-party/proxy-rebuild'); + expect(JSON.parse(fallback.searchParams.get('add') ?? '{}')).toEqual({ + [param]: param === 'bar' ? '2' : '3', + }); + expect(anchor.getAttribute('data-tsclick')).toBe(FIRST_PARTY_CLICK); + anchor.click(); + await vi.runAllTimersAsync(); + expect(openMock).toHaveBeenLastCalledWith( + fallback.toString(), + '_blank', + 'noopener,noreferrer' + ); + } + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it('keeps the captured authority for an opaque long-URL form fallback', async () => { + setCurrentScript(); + vi.stubGlobal('location', { href: 'about:srcdoc', origin: 'null' }); + Object.defineProperty(window, 'origin', { configurable: true, value: 'null' }); + const fetchMock = vi.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const submitMock = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {}); + const filler = 'a'.repeat(6800); + const signed = `${ENDPOINT_ORIGIN}${FIRST_PARTY_CLICK}&pad=${filler}`; const anchor = document.createElement('a'); - anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); - anchor.setAttribute('href', FIRST_PARTY_CLICK); + anchor.setAttribute('data-tsclick', signed); + anchor.setAttribute('href', signed); + anchor.target = '_blank'; document.body.appendChild(anchor); - await importCreativeModule(); - anchor.setAttribute('href', MUTATED_CLICK); - await Promise.resolve(); await vi.runAllTimersAsync(); - const fallback = anchor.getAttribute('href') ?? ''; - expect(fallback).toMatch(/^https:\/\/ads\.example\.com:8443\/first-party\/proxy-rebuild\?/); - expect(anchor.getAttribute('data-tsclick')).toBe(FIRST_PARTY_CLICK); + anchor.setAttribute('href', `https://example.com/landing?bar=2&pad=${filler}`); + await vi.runAllTimersAsync(); + anchor.click(); + await vi.runAllTimersAsync(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(submitMock).toHaveBeenCalledTimes(1); + const form = document.querySelector('form'); + expect(form?.action).toBe(`${ENDPOINT_ORIGIN}/first-party/proxy-rebuild`); + expect(form?.method).toBe('post'); + expect(form?.target).toBe('_blank'); + expect(form?.querySelector('input[name="tsclick"]')?.value).toBe(signed); + expect(form?.querySelector('input[name="add"]')?.value).toBe('{"bar":"2"}'); + expect(anchor.getAttribute('data-tsclick')).toBe(signed); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts index a9a0ce6b59..1cde2e9757 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts @@ -1,66 +1,138 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const currentScriptDescriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); +const stampDescriptor = Object.getOwnPropertyDescriptor(window, '__tsCreativeOrigin'); +const SCRIPT_ORIGIN = 'https://ads.example.com:8443'; +const STAMP_ORIGIN = 'https://publisher.example'; function setCurrentScript(src?: string): void { const script = src ? Object.assign(document.createElement('script'), { src }) : null; - Object.defineProperty(document, 'currentScript', { - configurable: true, - value: script, - }); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); } -afterEach(() => { +function setStamp(origin: string): void { + Object.defineProperty(window, '__tsCreativeOrigin', { configurable: true, value: origin }); +} + +beforeEach(() => { vi.resetModules(); + vi.stubGlobal('document', document.implementation.createHTMLDocument('creative')); + vi.stubGlobal('location', { + href: 'https://foreign.example/creative', + origin: 'https://foreign.example', + }); + setCurrentScript(); +}); + +afterEach(() => { vi.unstubAllGlobals(); - if (currentScriptDescriptor) { - Object.defineProperty(document, 'currentScript', currentScriptDescriptor); - } else { - Reflect.deleteProperty(document, 'currentScript'); - } + if (stampDescriptor) Object.defineProperty(window, '__tsCreativeOrigin', stampDescriptor); + else Reflect.deleteProperty(window, '__tsCreativeOrigin'); }); describe('creative/first_party.ts', () => { - it('captures the injected classic script origin and resolves endpoints against it', async () => { - setCurrentScript('https://ads.example.com:8443/static/tsjs=tsjs-unified.min.js?v=hash'); - const { firstPartyOrigin, resolveFirstPartyPath, isFirstPartyProxyUrl } = + it('pins script origin ahead of a different document and parent stamp', async () => { + setCurrentScript(`${SCRIPT_ORIGIN}/static/tsjs=tsjs-unified.min.js?v=hash`); + setStamp(STAMP_ORIGIN); + const { firstPartyOrigin, resolveFirstPartyPath } = await import('../../../src/integrations/creative/first_party'); - expect(firstPartyOrigin()).toBe('https://ads.example.com:8443'); - expect(resolveFirstPartyPath('/first-party/sign')).toBe( - 'https://ads.example.com:8443/first-party/sign' - ); - expect(isFirstPartyProxyUrl('https://ads.example.com:8443/first-party/proxy?token=1')).toBe( - true - ); - expect(isFirstPartyProxyUrl('https://ads.example.com:8443/first-party/proxy-extra')).toBe( - false - ); - expect(isFirstPartyProxyUrl('https://foreign.example/first-party/proxy')).toBe(false); + expect(firstPartyOrigin()).toBe(SCRIPT_ORIGIN); + expect(resolveFirstPartyPath('/first-party/sign')).toBe(`${SCRIPT_ORIGIN}/first-party/sign`); + + setCurrentScript('https://changed.example/runtime.js'); + setStamp('https://changed.example'); + vi.stubGlobal('location', { + href: 'https://changed.example/', + origin: 'https://changed.example', + }); + expect(firstPartyOrigin()).toBe(SCRIPT_ORIGIN); + }); + + it.each([ + [`${SCRIPT_ORIGIN}/first-party/proxy?token=1`, true], + ['/first-party/proxy?token=1', true], + ['//ads.example.com:8443/first-party/proxy', true], + [`${SCRIPT_ORIGIN}/first-party/proxy-extra`, false], + [`${SCRIPT_ORIGIN}/first-party/proxy/`, false], + [`${SCRIPT_ORIGIN}/first-party/Proxy`, false], + ['https://ads.example.com/first-party/proxy', false], + ['http://ads.example.com:8443/first-party/proxy', false], + [`${STAMP_ORIGIN}/first-party/proxy`, false], + ['https://foreign.example/first-party/proxy', false], + ])('recognizes only the exact proxy on the endpoint origin: %s', async (value, expected) => { + setCurrentScript(`${SCRIPT_ORIGIN}/static/runtime.js`); + setStamp(STAMP_ORIGIN); + const { isFirstPartyProxyUrl } = await import('../../../src/integrations/creative/first_party'); + expect(isFirstPartyProxyUrl(value)).toBe(expected); }); - it('captures an absolute script URL inside a srcdoc document', async () => { - vi.resetModules(); + it('captures an absolute script URL inside an opaque srcdoc document', async () => { vi.stubGlobal('location', { href: 'about:srcdoc', origin: 'null' }); - expect(location.href).toBe('about:srcdoc'); - setCurrentScript('https://ads.example.com:8443/static/tsjs=tsjs-unified.min.js?v=hash'); + setCurrentScript(`${SCRIPT_ORIGIN}/static/tsjs=tsjs-unified.min.js?v=hash`); + setStamp(STAMP_ORIGIN); const { firstPartyOrigin, resolveFirstPartyPath } = await import('../../../src/integrations/creative/first_party'); - expect(firstPartyOrigin()).toBe('https://ads.example.com:8443'); + expect(firstPartyOrigin()).toBe(SCRIPT_ORIGIN); expect(resolveFirstPartyPath('/first-party/proxy-rebuild')).toBe( - 'https://ads.example.com:8443/first-party/proxy-rebuild' + `${SCRIPT_ORIGIN}/first-party/proxy-rebuild` ); }); it('falls back to the document origin when currentScript is unavailable', async () => { - setCurrentScript(); const { firstPartyOrigin, resolveFirstPartyPath } = await import('../../../src/integrations/creative/first_party'); expect(firstPartyOrigin()).toBe(location.origin); expect(resolveFirstPartyPath('/first-party/proxy-rebuild')).toBe( - new URL('/first-party/proxy-rebuild', location.href).toString() + 'https://foreign.example/first-party/proxy-rebuild' ); }); + + it.each(['https://foreign.example/creative', 'about:srcdoc'])( + 'falls back to the pinned parent stamp without currentScript at %s', + async (href) => { + vi.stubGlobal('location', { href, origin: new URL(href).origin }); + setStamp(STAMP_ORIGIN); + const { firstPartyOrigin, resolveFirstPartyPath } = + await import('../../../src/integrations/creative/first_party'); + + expect(firstPartyOrigin()).toBe(STAMP_ORIGIN); + expect(resolveFirstPartyPath('/first-party/sign')).toBe(`${STAMP_ORIGIN}/first-party/sign`); + setStamp('https://changed.example'); + expect(firstPartyOrigin()).toBe(STAMP_ORIGIN); + } + ); + + it('uses the inherited base origin for unstamped srcdoc without currentScript', async () => { + vi.stubGlobal('location', { href: 'about:srcdoc', origin: 'null' }); + Object.defineProperty(document, 'baseURI', { + configurable: true, + value: 'http://localhost:7676/article', + }); + const { firstPartyOrigin, resolveFirstPartyPath } = + await import('../../../src/integrations/creative/first_party'); + + expect(firstPartyOrigin()).toBe('http://localhost:7676'); + expect(resolveFirstPartyPath('/first-party/sign')).toBe( + 'http://localhost:7676/first-party/sign' + ); + }); + + it('does not return the string null when no usable origin exists', async () => { + vi.stubGlobal('location', { href: 'about:srcdoc', origin: 'null' }); + const { firstPartyOrigin, resolveFirstPartyPath, isFirstPartyProxyUrl } = + await import('../../../src/integrations/creative/first_party'); + + expect(firstPartyOrigin()).toBeNull(); + expect(resolveFirstPartyPath('/first-party/sign')).toBe('/first-party/sign'); + expect(isFirstPartyProxyUrl('/first-party/proxy')).toBe(false); + }); + + it('ignores an unusable script origin and falls back to the pinned stamp', async () => { + setCurrentScript('data:text/javascript,void%200'); + setStamp(STAMP_ORIGIN); + const { firstPartyOrigin } = await import('../../../src/integrations/creative/first_party'); + expect(firstPartyOrigin()).toBe(STAMP_ORIGIN); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts index 147a6ca387..a6a2bdf7ef 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts @@ -25,8 +25,13 @@ describe('creative/proxy_sign.ts', () => { expect(shouldProxyExternalUrl('https://foreign.example/first-party/proxy?foo=1')).toBe(true); }); - it('trusts only the captured origin and exact proxy endpoint', async () => { + it('uses the captured endpoint rather than a different parent stamp for signing', async () => { const descriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); + const stampDescriptor = Object.getOwnPropertyDescriptor(window, '__tsCreativeOrigin'); + Object.defineProperty(window, '__tsCreativeOrigin', { + configurable: true, + value: 'https://publisher.example', + }); const script = Object.assign(document.createElement('script'), { src: 'https://ads.example.com/static/tsjs=tsjs-unified.min.js?v=hash', }); @@ -34,14 +39,30 @@ describe('creative/proxy_sign.ts', () => { vi.resetModules(); try { - const { shouldProxyExternalUrl: shouldProxy } = + const { shouldProxyExternalUrl: shouldProxy, signProxyUrl: sign } = await import('../../../src/integrations/creative/proxy_sign'); expect(shouldProxy('https://ads.example.com/first-party/proxy?token=1')).toBe(false); expect(shouldProxy('https://ads.example.com/first-party/proxy-extra?token=1')).toBe(true); expect(shouldProxy('https://foreign.example/first-party/proxy?token=1')).toBe(true); + const signed = 'https://ads.example.com/first-party/proxy?token=1'; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ href: signed }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + expect(await sign('https://cdn.example/asset.js')).toEqual({ + outcome: 'signed', + href: signed, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ads.example.com/first-party/sign', + expect.objectContaining({ method: 'POST', credentials: 'same-origin' }) + ); } finally { if (descriptor) Object.defineProperty(document, 'currentScript', descriptor); else Reflect.deleteProperty(document, 'currentScript'); + if (stampDescriptor) Object.defineProperty(window, '__tsCreativeOrigin', stampDescriptor); + else Reflect.deleteProperty(window, '__tsCreativeOrigin'); } }); From 99d2109c2d10c6b3806a993886dc4b272e61d94e Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 29 Oct 2026 13:11:05 -0500 Subject: [PATCH 3/8] Refresh creative URL fixtures and the browser origin contract --- .github/workflows/integration-tests.yml | 8 + .../browser/helpers/infra.ts | 82 ++++--- .../tests/shared/creative-sandbox.spec.ts | 215 ++++++++++-------- .../src/bin/generate-viceroy-config.rs | 190 ++++++++++++++-- .../tests/common/config.rs | 166 +++++++++++++- .../tests/environments/axum.rs | 4 +- .../tests/environments/cloudflare.rs | 29 ++- .../tests/environments/fastly.rs | 25 +- docs/guide/api-reference.md | 18 +- docs/guide/creative-processing.md | 50 ++-- docs/guide/first-party-proxy.md | 67 +++--- docs/guide/testing.md | 11 +- ...9-absolute-creative-rewrite-urls-design.md | 172 ++++++++++++++ trusted-server.example.toml | 9 +- 14 files changed, 822 insertions(+), 224 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-29-absolute-creative-rewrite-urls-design.md diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ca9d35ad41..4244aab555 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -158,6 +158,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 @@ -201,6 +203,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/crates/trusted-server-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts index 0402bb266e..657fb90b92 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts @@ -1,6 +1,9 @@ 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"; @@ -92,36 +95,63 @@ export async function startViceroy( configPath: 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}`; + 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", + baseUrl, + ]); + 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"] }, + ); - return { process: child, baseUrl }; + // 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}`); + }); + + 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/creative-sandbox.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts index 580e7db3e4..cf6e63146f 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 @@ -4,9 +4,9 @@ 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. +// cover uses the public origin captured from the absolute runtime script, +// skips the CORS-doomed POST and navigates the signed GET rebuild/click chain. +// These conditions are only observable in a real browser. const CREATIVE_SANDBOX_TOKENS = [ "allow-forms", "allow-popups", @@ -17,64 +17,82 @@ const CREATIVE_SANDBOX_TOKENS = [ // 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 parent stamp must stay immutable, even when its origin differs +// from the runtime script's public origin. 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) => - (element as HTMLScriptElement).src.includes("/static/tsjs="), + for (const parent of ["first-party", "foreign"] as const) { + test(`recovers a mutated click through the signed GET rebuild chain from a ${parent} parent`, 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 } }, ); - 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 = ` + 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); + + // The parent stamps its own origin before any creative markup. A foreign + // parent's stamp must not override the public origin of the absolute + // runtime script and signed click. Hostile markup still tries to move it. + const creativeDocument = ` @@ -85,51 +103,52 @@ test.describe("Sandboxed creative iframe", () => { `; - 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 = 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; + // Neither the parent origin nor hostile stamp overwrite may move the + // rebuild away from the public runtime origin. + expect(new URL(rebuild.url()).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 aada5dcf54..ff9ff1a166 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 @@ -15,9 +15,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, } fn main() -> Result<(), DynError> { @@ -31,15 +32,26 @@ 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 generated_config = inject_generated_config_stores(&template, &envelope_json)?; + 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(), + )?; + inject_generated_config_stores(&template, &envelope_json)? + } 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| { @@ -64,12 +76,16 @@ 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 iter = args.into_iter(); while let Some(arg) = iter.next() { 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")?), "--help" | "-h" => return Err(error_box(usage())), @@ -85,10 +101,10 @@ fn parse_args(args: impl IntoIterator) -> Result 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, }) } @@ -108,19 +124,31 @@ fn next_string_arg( } fn usage() -> String { - "usage: generate-viceroy-config --template --app-config --output [--origin-url ]".to_string() + "usage: generate-viceroy-config --template [--app-config ] --output [--origin-url ] [--public-origin ]\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:?}")))?; @@ -134,6 +162,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 { @@ -263,17 +316,103 @@ 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()) + origin_url: Some("http://127.0.0.1:9999".to_string()), + public_origin: None }, "should parse expected args" ); } + #[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"); @@ -299,7 +438,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"); @@ -315,7 +455,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, @@ -332,7 +472,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"); } @@ -341,7 +481,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!( @@ -350,6 +490,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 db4e011c5e..500c3b1524 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -1,21 +1,25 @@ 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}; const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.integration.toml"); -pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { - app_config_envelope(origin_port, |_| Ok(())) +pub fn integration_app_config_envelope( + origin_port: u16, + public_origin: &str, +) -> TestResult { + app_config_envelope(origin_port, Some(public_origin), |_| Ok(())) } /// Builds the integration app-config envelope after `customize` edits the /// parsed fixture. fn app_config_envelope( origin_port: u16, + public_origin: Option<&str>, customize: impl FnOnce(&mut toml::Table) -> TestResult<()>, ) -> TestResult { let origin_url = format!("http://127.0.0.1:{origin_port}"); @@ -33,6 +37,7 @@ fn app_config_envelope( })?; let mut settings = app_config.into_settings(); settings.publisher.origin_url = origin_url; + settings.publisher.public_origin = public_origin.map(str::to_owned); let app_config = TrustedServerAppConfig::new(settings).map_err(|report| { Report::new(TestError::ConfigGeneration) .attach(format!("invalid generated integration config: {report:?}")) @@ -51,7 +56,7 @@ fn app_config_envelope( } pub fn cloudflare_config_json(origin_port: u16) -> TestResult { - let envelope = integration_app_config_envelope(origin_port)?; + let envelope = app_config_envelope(origin_port, None, |_| Ok(()))?; cloudflare_binding_json(&serde_json::json!({ CONFIG_BLOB_KEY: envelope })) } @@ -60,7 +65,7 @@ pub fn cloudflare_config_json(origin_port: u16) -> TestResult { /// The fixture's providers are removed so the enabled auction completes /// through the orchestrator and telemetry without any outbound provider call. pub fn cloudflare_auction_config_json(origin_port: u16) -> TestResult { - let envelope = app_config_envelope(origin_port, |table| { + let envelope = app_config_envelope(origin_port, None, |table| { let auction = table_mut(table, "auction")?; auction.insert("enabled".to_string(), toml::Value::Boolean(true)); auction.remove("providers"); @@ -70,6 +75,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. +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) @@ -91,10 +153,104 @@ fn cloudflare_binding_json(binding: &serde_json::Value) -> TestResult { #[cfg(test)] mod tests { + use super::*; + const FASTLY_CONFIG: &str = include_str!("../../../../fastly.toml"); 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 json = integration_app_config_envelope(9999, &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 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, None, |_| 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 6e1388cea3..16b838d8da 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -58,7 +58,8 @@ impl RuntimeEnvironment for AxumDevServer { let binary = self.binary_path(); let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT); - let app_config = integration_app_config_envelope(origin_port())?; + let base_url = format!("http://127.0.0.1:{port}"); + let app_config = integration_app_config_envelope(origin_port(), &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); @@ -88,7 +89,6 @@ impl RuntimeEnvironment for 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 72328f42f1..9954e63b40 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, }; @@ -66,13 +66,12 @@ impl RuntimeEnvironment for CloudflareWorkers { } fn spawn(&self, _wasm_path: &Path) -> TestResult { - 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())?)? + if std::env::var("CI").is_ok() { + self.spawn_with_config_json(&cloudflare_config_json(origin_port())?) } else { - "wrangler.toml".to_string() - }; - self.spawn_wrangler(&wrangler_dir, &config) + let port = super::find_available_port().unwrap_or(CLOUDFLARE_DEFAULT_PORT); + self.spawn_wrangler(&self.wrangler_dir(), "wrangler.toml", port) + } } fn health_check_path(&self) -> &str { @@ -93,14 +92,20 @@ 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) + let config = write_generated_ci_config(&wrangler_dir, &config_json)?; + self.spawn_wrangler(&wrangler_dir, &config, port) } - fn spawn_wrangler(&self, wrangler_dir: &Path, config: &str) -> TestResult { - let port = super::find_available_port().unwrap_or(CLOUDFLARE_DEFAULT_PORT); - + fn spawn_wrangler( + &self, + wrangler_dir: &Path, + config: &str, + port: u16, + ) -> TestResult { #[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/docs/guide/api-reference.md b/docs/guide/api-reference.md index eecd89a27c..a687703edb 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -562,7 +562,10 @@ curl -X POST https://edge.example.com/first-party/sign \ } ``` -`href` is the signed proxy path. `base` is the normalized target without its query or fragment. +`href` is an absolute signed proxy URL on `publisher.public_origin`, falling +back to `https://{publisher.domain}`. `base` is the normalized target without +its query or fragment. Configure the actual browser-facing endpoint as described +in [Publisher configuration](/guide/configuration#publisher-configuration). **Error Responses:** @@ -580,7 +583,7 @@ curl -X POST https://edge.example.com/first-party/sign \ --- -### GET/POST /first-party/proxy-rebuild +### POST /first-party/proxy-rebuild URL mutation recovery endpoint. Re-signs a click URL after creative JavaScript modifies its query parameters. The original `tstoken` is validated first, and `tsurl`, `tstoken`, and `tsexp` can never be added or removed. @@ -607,7 +610,9 @@ Validation and re-signing depend on `publisher.proxy_secret`. } ``` -`tsclick` may be root-relative (the form the rewriter emits) or absolute. +`tsclick` accepts absolute URLs emitted by the rewriter and existing +root-relative signed URLs. The rebuilt `href` is absolute on +`publisher.public_origin`, falling back to `https://{publisher.domain}`. **Response:** @@ -629,7 +634,7 @@ Validation and re-signing depend on `publisher.proxy_secret`. ### GET /first-party/proxy-rebuild -Navigation form of the same recovery, for creatives rendered in a sandboxed iframe without `allow-same-origin`. Their opaque origin makes the JSON POST a CORS-preflighted cross-origin request that the endpoint does not answer, so the click guard navigates here instead — navigations are not subject to CORS. +Navigation form of the same recovery, for creatives rendered in a sandboxed iframe without `allow-same-origin`. Their opaque origin makes the JSON POST a CORS-preflighted cross-origin request that the endpoint does not answer, so the click guard navigates here instead. Navigations are not subject to CORS. **Query Parameters:** @@ -639,7 +644,10 @@ Navigation form of the same recovery, for creatives rendered in a sandboxed ifra | `add` | No | URL-encoded JSON object of parameters to add | | `del` | No | URL-encoded JSON array of parameter names to drop | -**Response:** `302` with the rebuilt `/first-party/click?...` URL in `Location` and `Cache-Control: no-store, private`. The browser follows it to `/first-party/click`, which redirects on to the advertiser. +**Response:** `302` with an absolute rebuilt click URL on +`publisher.public_origin`, falling back to `https://{publisher.domain}`, in +`Location` and `Cache-Control: no-store, private`. The browser follows it to +`/first-party/click`, which redirects on to the advertiser. Validation is identical to the POST form. diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index bfc09f0743..73c26dcfed 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -312,11 +312,11 @@ it does not control whether the processed `adm` is present. ```html - + ``` @@ -584,18 +584,18 @@ Descriptors are preserved exactly as written: ``` @@ -606,13 +606,13 @@ Srcset can mix absolute and relative URLs: ```html - + ``` @@ -645,23 +645,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,16 +674,16 @@ 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(https://ads.publisher.example/first-party/proxy?tsurl=https://cdn.com/top.png&tstoken=sig1) + 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.com/bottom.png&tstoken=sig2) + url(https://ads.publisher.example/first-party/proxy?tsurl=https://cdn.example.com/bottom.png&tstoken=sig2) bottom; } ``` @@ -694,10 +694,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(https://ads.publisher.example/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 @@ -747,7 +747,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. diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index 59fc85f3d9..2f0dc2d415 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -14,15 +14,26 @@ The First-Party Proxy system rewrites third-party URLs in ad creatives to route ## 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. Configure a browser-reachable HTTPS public origin for deployments; HTTP is supported for localhost development. +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. Use the actual browser-facing +HTTPS endpoint in deployments, or `http://localhost:8080` for a direct local +server on port 8080. When omitted, the field defaults to +`https://{publisher.domain}`. This keeps old configs deserializable, not the +serving page's scheme, host, or port. The publisher SSAT/page-bids path retains +its explicit request-origin override. See +[Publisher configuration](/guide/configuration#publisher-configuration) for +binary/config upgrade and rollback sequencing. ## How It Works ```mermaid flowchart TD - original["`Creative (Original) <img src='tracker.com/pixel.gif' />`"] - rewritten["Creative (Rewritten)
<img src='https://ads.publisher.example/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 ``` @@ -71,14 +82,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: ``` https://ads.publisher.example/first-party/proxy? - tsurl=https://tracker.com/pixel.gif& + tsurl=https://tracker.example.com/pixel.gif& campaign=123& uid=abc& tstoken=HmacSha256Signature @@ -87,7 +98,7 @@ https://ads.publisher.example/first-party/proxy? 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 @@ -97,7 +108,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` @@ -120,8 +131,8 @@ Click URL in creative: ```html @@ -133,7 +144,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 @@ -163,7 +174,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" } ``` @@ -203,7 +214,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**: @@ -228,7 +240,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"] @@ -247,8 +259,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 @@ -306,12 +319,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); } ``` @@ -334,7 +347,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) @@ -372,7 +385,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 ``` @@ -392,11 +405,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 @@ -420,14 +433,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 @@ -722,8 +737,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/testing.md b/docs/guide/testing.md index c34fcc57e6..a4d75e3992 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -6,7 +6,16 @@ the adapters do not share one compilation target. ## Testing absolute creative rewrite URLs -Creative rewrite tests should configure a distinct `publisher.public_origin` and assert the parsed URL origin, not only a `/first-party/...` path substring. Keep manual root-relative proxy and click fixtures for compatibility tests, because deployed creative output is absolute on the configured public origin. +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. The fallback is +`https://{publisher.domain}`, not the serving page's scheme, host, or port. +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. ## Required gates 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..e4be001c08 --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-absolute-creative-rewrite-urls-design.md @@ -0,0 +1,172 @@ +# 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. + +## 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] + Stamp[Existing trusted parent stamp] --> Legacy[Fallback resolution for legacy relative navigation] +``` + +- Qualify the shared signed-URL builder's root-relative outputs. HTML attributes, + CSS, `srcset` and `/first-party/sign` then share one output rule. +- 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. + +Use the endpoint origin for sign/rebuild requests and exact-origin/exact-path +proxy recognition. 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. +Operators must choose an appropriate browser-reachable endpoint. No CORS +grants, signing broker, retry framework or dynamic-signing transport redesign; +#982 remains separate. + +## Compatibility and rollout + +Configuration compatibility is not routing compatibility. Previously generated +root-relative URLs followed the visitor's scheme, host and port. The new absent +value fallback always uses HTTPS and the configured publisher domain. This may +change the endpoint for subdomain or local HTTP deployments. + +Verify `public_origin`, DNS/TLS, routes and local ports before rollout. When the +fallback is not the reachable endpoint, activate the new binary with its matching +configuration blob as a coordinated release. Old binaries deny the new key, so +do not publish a configured `public_origin` to an old active binary. Rollback must +restore an old-compatible blob without the key together with the old binary. +Retain the public endpoint while previously emitted absolute creative URLs may +still be used. Existing signed relative inputs remain accepted on the new binary. + +## Verification + +- 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 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/trusted-server.example.toml b/trusted-server.example.toml index 0122c3bdd1..d707643e01 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -59,7 +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" -# Browser-facing Trusted Server origin used in rewritten creative URLs. Use HTTPS in deployments. +# Actual browser-facing Trusted Server endpoint for rewritten creative URLs. +# Use HTTPS in deployments, or e.g. http://localhost:8080 for direct local dev. +# Optional for old-config deserialization. Omission uses https://{domain}, NOT +# the serving page's scheme, host, or port. This active leaf allows env overlays. +# Upgrade binary before pushing this field; old binaries reject it. Coordinate +# binary/config activation if https://{domain} cannot serve the fallback traffic. +# Rollback: remove this field and its env override, validate and push, then roll +# back the binary. Coordinate both if the fallback endpoint cannot serve traffic. 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" From a35fef44fb61f1450061bcefc0c20be367aef92a Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 30 Oct 2026 11:23:11 -0500 Subject: [PATCH 4/8] Address absolute creative URL review feedback Reject the public-origin template placeholder, warn on the retained domain fallback, preserve validation reasons, and reject port zero.\n\nRemove the redundant rebuild authority guard and unused script-tag helper. Retain script-origin precedence with explicit spoofing-limit coverage, use the configured inline fallback, and centralize rollout and EC-cookie guidance.\n\nRetarget template-based audit generators and test harnesses to their actual public endpoints. --- CHANGELOG.md | 2 +- .../src/commands/audit/generate/mod.rs | 11 +++ .../src/commands/audit/generate/validate.rs | 4 + .../tests/audit_config_diagnostics.rs | 4 + .../tests/config_env_overlay.rs | 82 +++++++++++++++- .../tests/config_store_defaults.rs | 4 + .../trusted-server-core/src/auction/README.md | 11 +-- crates/trusted-server-core/src/config.rs | 98 ++++++++++++++++++- crates/trusted-server-core/src/proxy.rs | 47 +++++---- crates/trusted-server-core/src/publisher.rs | 61 ++++++------ crates/trusted-server-core/src/settings.rs | 87 +++++++++++++++- crates/trusted-server-core/src/tsjs.rs | 23 +---- .../tests/shared/creative-sandbox.spec.ts | 98 ++++++++++++++----- .../tests/html_eof_runtime.rs | 4 +- .../tests/html_eof_runtime/config.rs | 21 +++- .../trusted-server-js/lib/src/core/render.ts | 12 ++- .../lib/src/core/templates/iframe.html | 11 +-- .../src/integrations/creative/first_party.ts | 14 ++- .../lib/src/shared/origin.ts | 18 ++-- .../lib/test/core/render.test.ts | 11 ++- .../integrations/creative/first_party.test.ts | 35 ++++++- docs/guide/api-reference.md | 12 +-- docs/guide/auction-orchestration.md | 15 ++- docs/guide/configuration.md | 47 +++++---- docs/guide/creative-processing.md | 26 ++--- docs/guide/first-party-proxy.md | 12 +-- docs/guide/testing.md | 13 --- ...9-absolute-creative-rewrite-urls-design.md | 35 ++++--- scripts/template-cache-local-test.sh | 10 +- trusted-server.example.toml | 10 +- 30 files changed, 611 insertions(+), 227 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cda0977c3..8e897551e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +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 optional `publisher.public_origin`, falling back to `https://{publisher.domain}`. Omission keeps old configs deserializable but does not preserve the serving page's scheme, host, or port as root-relative output did. Configure the actual browser-facing endpoint, including the port for local development, such as `http://localhost:8080`. Existing root-relative signed proxy and click inputs remain accepted. TSJS bundle selection and hash calculation are unchanged. Upgrade the binary before pushing an explicit `public_origin`; old binaries reject the field. Before rollback, remove the field and its environment override, validate and push, then restore the old binary. Coordinate binary/config activation or rollback when the fallback endpoint cannot serve traffic. +- **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](docs/guide/configuration.md#public_origin) before deployment. Existing root-relative signed proxy and click inputs remain accepted. TSJS bundle selection and hash calculation are unchanged. - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. - The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave. 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 1c35db13ca..302d0108fb 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 @@ -2650,6 +2656,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\"")); @@ -3817,6 +3824,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/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 87df5a447f..65414d74f2 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#"", - 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 { @@ -269,11 +258,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 +320,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/tests/shared/creative-sandbox.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts index cf6e63146f..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 uses the public origin captured from the absolute runtime script, -// skips the CORS-doomed POST and navigates the signed GET rebuild/click chain. -// These conditions are 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,17 +15,27 @@ 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 -// . The parent stamp must stay immutable, even when its origin differs -// from the runtime script's public 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", () => { - for (const parent of ["first-party", "foreign"] as const) { - test(`recovers a mutated click through the signed GET rebuild chain from a ${parent} parent`, async ({ + 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; @@ -84,9 +94,18 @@ test.describe("Sandboxed creative iframe", () => { } expect(await page.evaluate(() => location.origin)).toBe(parentOrigin); - // The parent stamps its own origin before any creative markup. A foreign - // parent's stamp must not override the public origin of the absolute - // runtime script and signed click. Hostile markup still tries to move it. + // 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", + }), + ); + + // A foreign parent's stamp does not override the runtime script origin. + // Nor does the stamp authenticate currentScript against earlier markup. const creativeDocument = ` @@ -95,10 +114,11 @@ test.describe("Sandboxed creative iframe", () => { value: '${parentOrigin}', writable: false, configurable: false, enumerable: false, }); + ${attack === "before capture" ? HOSTILE_STAMP_OVERWRITE : ""} - ${HOSTILE_STAMP_OVERWRITE} + ${attack === "after capture" ? HOSTILE_STAMP_OVERWRITE : ""} ad `; @@ -107,12 +127,15 @@ test.describe("Sandboxed creative iframe", () => { (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 }, - ); + 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 }) => { @@ -129,6 +152,19 @@ test.describe("Sandboxed creative iframe", () => { 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. @@ -139,12 +175,24 @@ test.describe("Sandboxed creative iframe", () => { await link.click({ force: true }); const rebuild = await rebuildResponse; - // Neither the parent origin nor hostile stamp overwrite may move the - // rebuild away from the public runtime origin. - expect(new URL(rebuild.url()).origin).toBe(origin); + 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; + 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. 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..97c86844c7 100644 --- a/crates/trusted-server-integration-tests/tests/html_eof_runtime.rs +++ b/crates/trusted-server-integration-tests/tests/html_eof_runtime.rs @@ -598,8 +598,9 @@ fn actual_runtime() -> HarnessResult<()> { let mut baseline = Vec::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 +608,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)?; 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-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 @@ ", - first_party_url(settings, &tsjs::tsjs_unified_script_src()) +fn creative_runtime_script_tag(public_origin: &str) -> String { + tsjs::tsjs_script_tag_with_public_origin( + &first_party_url(public_origin, &tsjs::tsjs_unified_script_src()), + &[], + public_origin, ) } @@ -617,7 +617,7 @@ fn creative_runtime_script_tag(settings: &Settings) -> String { fn build_signed_url_for( settings: &Settings, clear_url: &str, - base_path: &str, + endpoint: &str, extra: &[(String, String)], ) -> String { let Ok(mut u) = url::Url::parse(clear_url) else { @@ -656,18 +656,12 @@ fn build_signed_url_for( qs.append_pair(k, v); } qs.append_pair("tstoken", &token); - let signed = format!("{}?{}", base_path, qs.finish()); - if base_path.starts_with('/') { - first_party_url(settings, &signed) - } else { - signed - } + format!("{}?{}", endpoint, qs.finish()) } /// Build an absolute signed first-party proxy URL. /// -/// An empty `base_origin` uses the configured public origin. A non-empty origin -/// preserves the publisher inline renderer's explicit request-origin override. +/// `base_origin` is resolved once by the caller before rewriting or signing. #[inline] pub(super) fn build_proxy_url(settings: &Settings, clear_url: &str, base_origin: &str) -> String { build_signed_url_for( @@ -682,9 +676,15 @@ pub(super) fn build_proxy_url(settings: &Settings, clear_url: &str, base_origin: pub(super) fn build_proxy_url_with_extras( settings: &Settings, clear_url: &str, + base_origin: &str, extra: &[(String, String)], ) -> String { - build_signed_url_for(settings, clear_url, "/first-party/proxy", extra) + build_signed_url_for( + settings, + clear_url, + &first_party_url(base_origin, "/first-party/proxy"), + extra, + ) } /// Build a signed first-party click URL, prefixing `base_origin` before the @@ -846,7 +846,14 @@ impl core::error::Error for CssRewriteError {} /// [`MAX_CSS_NESTING_DEPTH`], or [`CssRewriteError::OutputTooLarge`] when the /// rewritten output exceeds [`MAX_REWRITABLE_BODY_SIZE`]. pub fn rewrite_css_body(settings: &Settings, css: &str) -> Result { - rewrite_style_urls_in_context(settings, css, "", true, MAX_REWRITABLE_BODY_SIZE) + let public_origin = settings.publisher.effective_public_origin(); + rewrite_style_urls_in_context( + settings, + css, + &public_origin, + true, + MAX_REWRITABLE_BODY_SIZE, + ) } /// Maximum byte length of creative HTML accepted by [`sanitize_creative_html`]. @@ -1053,7 +1060,15 @@ pub fn sanitize_creative_html(markup: &str) -> String { #[must_use] pub(crate) fn process_auction_creative(settings: &Settings, raw: &str) -> String { process_auction_creative_with_rewriter(settings, raw, |sanitized, features| { - rewrite_creative_html_impl(settings, sanitized, "", true, MAX_CREATIVE_SIZE, features) + let public_origin = settings.publisher.effective_public_origin(); + rewrite_creative_html_impl( + settings, + sanitized, + &public_origin, + true, + MAX_CREATIVE_SIZE, + features, + ) }) } @@ -1069,10 +1084,11 @@ pub(crate) fn process_inline_auction_creative( raw: &str, ) -> String { process_auction_creative_with_rewriter(settings, raw, |sanitized, features| { + let public_origin = inline_public_origin(settings, base_origin); rewrite_creative_html_impl( settings, sanitized, - base_origin, + &public_origin, false, MAX_CREATIVE_SIZE, features, @@ -1137,7 +1153,7 @@ pub fn rewrite_creative_html(settings: &Settings, markup: &str) -> String { rewrite_creative_html_impl( settings, markup, - "", + &settings.publisher.effective_public_origin(), true, MAX_CREATIVE_SIZE, CreativeFeatures::ALL, @@ -1160,7 +1176,7 @@ pub fn rewrite_proxied_html(settings: &Settings, markup: &str) -> String { rewrite_creative_html_impl( settings, markup, - "", + &settings.publisher.effective_public_origin(), true, MAX_REWRITABLE_BODY_SIZE, CreativeFeatures { @@ -1200,16 +1216,29 @@ pub fn rewrite_inline_creative_html( base_origin: &str, markup: &str, ) -> String { + let public_origin = inline_public_origin(settings, base_origin); rewrite_creative_html_impl( settings, markup, - base_origin, + &public_origin, false, MAX_CREATIVE_SIZE, CreativeFeatures::ALL, ) } +/// Retains the inline renderer's request-origin override, including its empty fallback. +fn inline_public_origin<'a>( + settings: &Settings, + request_origin: &'a str, +) -> std::borrow::Cow<'a, str> { + if request_origin.is_empty() { + std::borrow::Cow::Owned(settings.publisher.effective_public_origin()) + } else { + std::borrow::Cow::Borrowed(request_origin) + } +} + /// The clear-price auction macro DSPs embed in creative markup and tracking URLs. const AUCTION_PRICE_MACRO: &str = "${AUCTION_PRICE}"; @@ -1258,8 +1287,8 @@ impl CreativeFeatures { } } -/// Shared creative rewriter. A non-empty `base_origin` overrides the configured -/// public origin for first-party proxy and click URLs; +/// Shared creative rewriter with an explicitly resolved `base_origin` for +/// first-party proxy, click and runtime URLs; /// `inject_tsjs` controls the `` tsjs bundle injection; `max_output_size` /// bounds the rewritten result; `features` selects which handlers run. `` /// handling always runs, and TSJS injection runs when `inject_tsjs` is set, @@ -1316,7 +1345,7 @@ fn rewrite_creative_html_impl( let injected = std::rc::Rc::clone(&injected_ts_creative); move |el| { if inject_tsjs && !injected.get() { - let script_tag = creative_runtime_script_tag(settings); + let script_tag = creative_runtime_script_tag(base_origin); el.prepend(&script_tag, ContentType::Html); injected.set(true); } @@ -1537,7 +1566,7 @@ fn rewrite_creative_html_impl( // rewrote to nothing, and a script-only result would read as an accepted // creative that renders blank. if inject_tsjs && !injected_ts_creative.get() && !rewritten.is_empty() { - let script_tag = creative_runtime_script_tag(settings); + let script_tag = creative_runtime_script_tag(base_origin); rewritten.insert_str(0, &script_tag); if rewritten.len() > max_output_size { log::warn!( @@ -2052,6 +2081,13 @@ mod tests { .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 [ @@ -2133,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, @@ -2148,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" @@ -2159,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}" @@ -2174,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}" ); @@ -2190,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}" @@ -2205,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" @@ -2224,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}" @@ -2240,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}" ); @@ -2261,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"); } @@ -2271,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="), @@ -2289,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!( @@ -2306,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!( @@ -2376,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 @@ -2397,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}" ); @@ -2416,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"] { @@ -2424,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}" ); @@ -2437,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}" ); @@ -2454,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}" ); @@ -2478,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") @@ -2499,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, @@ -2509,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}" ); @@ -2523,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}" ); @@ -2547,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("), @@ -2565,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, @@ -2578,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, @@ -2592,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}" ); @@ -2613,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}" ); @@ -2634,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}" ); } @@ -2661,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}" @@ -2683,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}" ); @@ -2705,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!( @@ -2716,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}" ); @@ -2730,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}" ); @@ -2752,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, @@ -2770,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}" ); @@ -2796,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}" ); @@ -2822,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"); } @@ -2835,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}" @@ -2860,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}" ); @@ -2883,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, @@ -2899,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, @@ -2915,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(), @@ -2942,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}" ); @@ -2960,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}" ); @@ -2976,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"); } @@ -3254,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" @@ -3301,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, ); @@ -3328,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" ); } @@ -3362,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")); @@ -3377,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); } @@ -3819,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" ); @@ -3830,7 +4032,12 @@ b{background:url(\"https://cdn.example/c.png\")}"; "https://notpublisher.example.org/lib.js", ] { assert!( - proxy_if_abs(&settings, proxied, "").is_some_and(|url| { + 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() @@ -4244,13 +4451,119 @@ b{background:url(\"https://cdn.example/c.png\")}"; ))); } + #[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", ""); - let click = build_click_url(&settings, "https://advertiser.example.com/landing", ""); + 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!( @@ -4291,7 +4604,11 @@ b{background:url(\"https://cdn.example/c.png\")}"; #[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", ""); + 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 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 b3901bea99..70e36f10e5 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -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, @@ -2030,7 +2036,11 @@ pub async fn handle_first_party_proxy_rebuild( qs.append_pair(k, v); } qs.append_pair("tstoken", &token); - let href = first_party_url(settings, &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(); @@ -3801,7 +3811,11 @@ mod tests { 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, ""); + let first_party = creative::build_proxy_url( + &settings, + 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"); @@ -3813,7 +3827,11 @@ 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 first_party = creative::build_proxy_url( + &settings, + clear, + &settings.publisher.effective_public_origin(), + ); let st = reconstruct_and_validate_signed_target(&settings, &first_party) .expect("reconstruct ok"); assert_eq!(st.tsurl, clear); @@ -3827,7 +3845,11 @@ mod tests { 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 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) @@ -3865,7 +3887,11 @@ 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 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 diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 72d0cb57d4..8d26af7859 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -10914,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(), diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index f9c1411ae6..65179c5294 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -6372,7 +6372,7 @@ mod tests { let generated = crate::creative::build_proxy_url( &settings, "https://cdn.example.com/ad.png", - "", + &settings.publisher.effective_public_origin(), ); let resolved = foreign .join(&generated) diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 0f154d8c9b..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 @@ -210,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" diff --git a/crates/trusted-server-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts index 657fb90b92..cd83913da5 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts @@ -10,7 +10,10 @@ 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 }, }; @@ -93,9 +96,14 @@ 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 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( @@ -120,7 +128,7 @@ export async function startViceroy( "--output", runtimeConfig, "--public-origin", - baseUrl, + publicOrigin.origin, ]); const child = spawn( "viceroy", 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-js/lib/src/integrations/creative/first_party.ts b/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts index 29c847e4e9..d38886dcff 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/first_party.ts @@ -1,23 +1,28 @@ import { normalizeTrustedOrigin, TRUSTED_BASE_URL } from '../../shared/origin'; // Capture the classic script origin synchronously so later currentScript/DOM -// changes cannot change the captured endpoint origin. Its public origin can differ -// from both the document and the rendering parent's stamp, so it takes precedence. -// This source is not authenticated: creative script that runs first can shadow -// currentScript and choose the captured endpoint origin despite the immutable -// parent stamp. +// changes cannot change the captured origins. The script origin chooses endpoints; +// server-configured metadata only recognizes already-proxied resources on another +// origin. Neither source is authenticated: creative script that runs first can +// shadow currentScript and choose both origins despite the immutable parent stamp. // Only when the script origin is unusable do we fall back to the pinned // stamp/document/baseURI resolution used by legacy relative clicks. An opaque // or otherwise unusable base supplies no origin. -const endpointOrigin: string | null = (() => { +const { endpointOrigin, configuredProxyOrigin } = (() => { + let scriptOrigin = ''; + let configuredProxyOrigin = ''; try { - const source = document.currentScript?.getAttribute('src') || document.currentScript?.src; - const origin = source && normalizeTrustedOrigin(new URL(source, location.href).origin); - if (origin) return origin; + const script = document.currentScript; + configuredProxyOrigin = normalizeTrustedOrigin(script?.getAttribute('data-ts-public-origin')); + const source = script?.getAttribute('src') || script?.src; + scriptOrigin = (source && normalizeTrustedOrigin(new URL(source, location.href).origin)) || ''; } catch { // Fall back to the pinned document origin below. } - return normalizeTrustedOrigin(TRUSTED_BASE_URL) || null; + return { + endpointOrigin: scriptOrigin || normalizeTrustedOrigin(TRUSTED_BASE_URL) || null, + configuredProxyOrigin, + }; })(); export function firstPartyOrigin(): string | null { @@ -35,11 +40,12 @@ export function resolveFirstPartyPath(path: string): string { } export function isFirstPartyProxyUrl(value: string): boolean { - const origin = firstPartyOrigin(); - if (!origin) return false; try { - const url = new URL(value, origin); - return url.origin === origin && url.pathname === '/first-party/proxy'; + const url = new URL(value, endpointOrigin || undefined); + return ( + url.pathname === '/first-party/proxy' && + (url.origin === endpointOrigin || url.origin === configuredProxyOrigin) + ); } catch { return false; } diff --git a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs index 2ef3c6ef2a..fb17a781af 100644 --- a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs @@ -45,6 +45,7 @@ function createPage({ storedDebug = false, origin = 'https://publisher.example.com', scriptSrc, + publicOrigin, stampOrigin, } = {}) { dom = new JSDOM('
', { @@ -76,6 +77,7 @@ function createPage({ if (scriptSrc) { const script = window.document.createElement('script'); script.src = scriptSrc; + if (publicOrigin !== undefined) script.setAttribute('data-ts-public-origin', publicOrigin); Object.defineProperty(window.document, 'currentScript', { configurable: true, value: script }); } // Match bundle.rs: core first and independent IIFEs joined into one script. @@ -110,6 +112,56 @@ function requestContext(window) { } describe('shared state across production IIFEs', () => { + it('recognizes the configured proxy origin across independent core and creative IIFEs without retargeting endpoints', async () => { + const scriptOrigin = 'https://www.publisher.example'; + const proxyOrigin = 'https://ads.publisher.example:8443'; + const { window } = createPage({ + modules: ['core', 'creative'], + origin: scriptOrigin, + scriptSrc: `${scriptOrigin}/static/tsjs=tsjs-unified.min.js?v=hash`, + publicOrigin: proxyOrigin, + }); + window.tscreative.setConfig({ renderGuard: true }); + const script = window.document.currentScript; + script.setAttribute('data-ts-public-origin', 'https://changed.example'); + Object.defineProperty(window.document, 'currentScript', { configurable: true, value: null }); + const proxy = `${proxyOrigin}/first-party/proxy?tsurl=https%3A%2F%2Fresource.example%2Fasset&tstoken=signed`; + const image = new window.Image(); + const iframe = window.document.createElement('iframe'); + image.src = proxy; + iframe.src = proxy; + window.document.body.append(image, iframe); + const container = window.document.createElement('div'); + container.innerHTML = ``; + window.document.body.appendChild(container); + await new Promise((resolve) => window.setTimeout(resolve, 10)); + for (const element of window.document.querySelectorAll('img, iframe')) { + expect(element.getAttribute('src')).toBe(proxy); + element.setAttribute('src', `${proxy}&version=2`); + } + await new Promise((resolve) => window.setTimeout(resolve, 10)); + for (const element of window.document.querySelectorAll('img, iframe')) { + expect(element.getAttribute('src')).toBe(`${proxy}&version=2`); + } + expect(window.fetch).not.toHaveBeenCalled(); + + window.fetch.mockResolvedValue({ ok: true, json: async () => ({ href: proxy }) }); + for (const external of [ + `${proxyOrigin}/first-party/proxy-extra`, + 'https://ads.publisher.example/first-party/proxy', + 'https://foreign.example/first-party/proxy', + 'https://changed.example/first-party/proxy', + ]) { + const externalImage = new window.Image(); + externalImage.src = external; + } + await vi.waitFor(() => expect(window.fetch).toHaveBeenCalledTimes(4)); + for (const [url, init] of window.fetch.mock.calls) { + expect(url).toBe(`${scriptOrigin}/first-party/sign`); + expect(init.credentials).toBe('same-origin'); + } + }); + it('keeps the captured creative endpoint and repaired click authority across independent IIFEs', async () => { const endpoint = 'https://ads.example.com:8443'; const original = `${endpoint}/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Flanding&foo=1&tstoken=original`; @@ -121,6 +173,7 @@ describe('shared state across production IIFEs', () => { modules: ['core', 'creative'], origin: 'https://foreign.example', scriptSrc: `${endpoint}/static/tsjs=tsjs-unified.min.js?v=hash`, + publicOrigin: 'https://configured.example:9443', stampOrigin: 'https://publisher.example', }); Object.defineProperty(window.document, 'currentScript', { configurable: true, value: null }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/configured_proxy.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/configured_proxy.test.ts new file mode 100644 index 0000000000..e48ef1d39f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/configured_proxy.test.ts @@ -0,0 +1,49 @@ +import { afterEach, expect, it, vi } from 'vitest'; + +import { importCreativeModule } from './helpers'; + +const currentScriptDescriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); + +afterEach(() => { + vi.unstubAllGlobals(); + if (currentScriptDescriptor) { + Object.defineProperty(document, 'currentScript', currentScriptDescriptor); + } else { + Reflect.deleteProperty(document, 'currentScript'); + } + document.body.innerHTML = ''; +}); + +it('leaves configured-origin images and iframes unchanged through setters and mutation processing', async () => { + vi.resetModules(); + const fetchMock = vi.fn().mockRejectedValue(new Error('unexpected signing request')); + vi.stubGlobal('fetch', fetchMock); + const script = document.createElement('script'); + script.src = `${location.origin}/static/runtime.js`; + script.setAttribute('data-ts-public-origin', 'https://ads.publisher.example:8443'); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + await importCreativeModule({ renderGuard: true }); + + const proxy = + 'https://ads.publisher.example:8443/first-party/proxy?tsurl=https%3A%2F%2Fresource.example%2Fasset&tstoken=signed'; + const image = new Image(); + const iframe = document.createElement('iframe'); + image.src = proxy; + iframe.src = proxy; + document.body.append(image, iframe); + // innerHTML bypasses the src setters and setAttribute hooks. These resources + // must pass through the guard's MutationObserver, including later changes. + const container = document.createElement('div'); + container.innerHTML = ``; + document.body.appendChild(container); + await new Promise((resolve) => setTimeout(resolve, 10)); + for (const element of document.querySelectorAll('img, iframe')) { + expect(element.getAttribute('src')).toBe(proxy); + element.setAttribute('src', `${proxy}&version=2`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + for (const element of document.querySelectorAll('img, iframe')) { + expect(element.getAttribute('src')).toBe(`${proxy}&version=2`); + } + expect(fetchMock).not.toHaveBeenCalled(); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts index ee85e3ec22..9e924bc183 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/first_party.test.ts @@ -4,9 +4,13 @@ const stampDescriptor = Object.getOwnPropertyDescriptor(window, '__tsCreativeOri const SCRIPT_ORIGIN = 'https://ads.example.com:8443'; const STAMP_ORIGIN = 'https://publisher.example'; -function setCurrentScript(src?: string): void { +function setCurrentScript(src?: string, publicOrigin?: string): HTMLScriptElement | null { const script = src ? Object.assign(document.createElement('script'), { src }) : null; + if (script && publicOrigin !== undefined) { + script.setAttribute('data-ts-public-origin', publicOrigin); + } Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return script; } function setStamp(origin: string): void { @@ -30,6 +34,102 @@ afterEach(() => { }); describe('creative/first_party.ts', () => { + it('does not proxy a server-rewritten resource when bundle and configured origins differ', async () => { + vi.stubGlobal('location', { + href: 'https://www.publisher.example/article', + origin: 'https://www.publisher.example', + }); + setCurrentScript( + 'https://www.publisher.example/static/runtime.js', + 'https://ads.publisher.example:8443' + ); + const { shouldProxyExternalUrl } = + await import('../../../src/integrations/creative/proxy_sign'); + + expect( + shouldProxyExternalUrl( + 'https://ads.publisher.example:8443/first-party/proxy?tsurl=https%3A%2F%2Fimg.example%2Fpixel.gif&tstoken=signed' + ) + ).toBe(false); + }); + + it.each([ + [`${SCRIPT_ORIGIN}/first-party/proxy?token=1`, true], + ['https://configured.example:9443/first-party/proxy?token=1', true], + ['https://configured.example:9443/first-party/proxy-extra', false], + ['https://configured.example:9443/first-party/proxy/', false], + ['https://configured.example:9443/first-party/Proxy', false], + ['https://configured.example/first-party/proxy', false], + ['https://configured.example:8443/first-party/proxy', false], + ['http://configured.example:9443/first-party/proxy', false], + ['https://foreign.example/first-party/proxy', false], + ])('recognizes exact proxy paths on either captured origin: %s', async (value, expected) => { + setCurrentScript(`${SCRIPT_ORIGIN}/static/runtime.js`, 'https://configured.example:9443'); + const { isFirstPartyProxyUrl, resolveFirstPartyPath } = + await import('../../../src/integrations/creative/first_party'); + + expect(isFirstPartyProxyUrl(value)).toBe(expected); + expect(resolveFirstPartyPath('/first-party/sign')).toBe(`${SCRIPT_ORIGIN}/first-party/sign`); + expect(resolveFirstPartyPath('/first-party/proxy-rebuild')).toBe( + `${SCRIPT_ORIGIN}/first-party/proxy-rebuild` + ); + }); + + it.each([ + undefined, + '', + 'not an origin', + '/relative', + 'null', + 'data:text/plain,origin', + 'ftp://configured.example', + ])('ignores missing or unusable configured metadata: %s', async (metadata) => { + setCurrentScript(`${SCRIPT_ORIGIN}/static/runtime.js`, metadata); + const { isFirstPartyProxyUrl, firstPartyOrigin } = + await import('../../../src/integrations/creative/first_party'); + expect(firstPartyOrigin()).toBe(SCRIPT_ORIGIN); + expect(isFirstPartyProxyUrl(`${SCRIPT_ORIGIN}/first-party/proxy`)).toBe(true); + expect(isFirstPartyProxyUrl('https://configured.example/first-party/proxy')).toBe(false); + }); + + it.each([ + ['https://CONFIGURED.example:443/', 'https://configured.example'], + ['http://CONFIGURED.example:80/', 'http://configured.example'], + ])('normalizes configured default ports: %s', async (metadata, origin) => { + setCurrentScript(`${SCRIPT_ORIGIN}/static/runtime.js`, metadata); + const { isFirstPartyProxyUrl } = await import('../../../src/integrations/creative/first_party'); + expect(isFirstPartyProxyUrl(`${origin}/first-party/proxy`)).toBe(true); + expect(isFirstPartyProxyUrl(`${origin}:9443/first-party/proxy`)).toBe(false); + }); + + it('reads the executing script once and pins metadata against later mutation and replacement', async () => { + const script = setCurrentScript( + `${SCRIPT_ORIGIN}/static/runtime.js`, + 'https://configured.example' + )!; + const getScript = vi.fn(() => script); + Object.defineProperty(document, 'currentScript', { configurable: true, get: getScript }); + const { firstPartyOrigin, isFirstPartyProxyUrl } = + await import('../../../src/integrations/creative/first_party'); + expect(getScript).toHaveBeenCalledTimes(1); + + script.setAttribute('data-ts-public-origin', 'https://changed.example'); + script.src = 'https://changed.example/runtime.js'; + setCurrentScript('https://replacement.example/runtime.js', 'https://replacement.example'); + expect(firstPartyOrigin()).toBe(SCRIPT_ORIGIN); + expect(isFirstPartyProxyUrl('https://configured.example/first-party/proxy')).toBe(true); + expect(isFirstPartyProxyUrl('https://changed.example/first-party/proxy')).toBe(false); + expect(isFirstPartyProxyUrl('https://replacement.example/first-party/proxy')).toBe(false); + }); + + it('captures spoofed configured metadata before startup, without authenticating it', async () => { + setCurrentScript(`${SCRIPT_ORIGIN}/static/runtime.js`, 'https://attacker.invalid'); + const { isFirstPartyProxyUrl, firstPartyOrigin } = + await import('../../../src/integrations/creative/first_party'); + expect(isFirstPartyProxyUrl('https://attacker.invalid/first-party/proxy')).toBe(true); + expect(firstPartyOrigin()).toBe(SCRIPT_ORIGIN); + }); + it('pins script origin ahead of a different document and parent stamp', async () => { setCurrentScript(`${SCRIPT_ORIGIN}/static/tsjs=tsjs-unified.min.js?v=hash`); setStamp(STAMP_ORIGIN); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a39a5aa7b3..a51c0dcd10 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -436,7 +436,15 @@ This does not enable cross-origin credentials: runtime fetches still use `credentials: 'same-origin'`, so even an in-scope endpoint on a different origin does not receive cookies on those fetches. No CORS grants are added. -**Format**: An `http` or `https` origin with a host and optional port, without userinfo, path, query, fragment, or a trailing slash. HTTPS is recommended for deployed publisher traffic; HTTP is supported for direct localhost development. +**Format**: An `http` or `https` origin with a host and optional port, without userinfo, path, query, fragment, or a trailing slash. HTTPS is recommended for deployed publisher traffic; HTTP remains supported. + +App-config loading warns when an explicit origin is outside the EC cookie scope +or uses HTTP on a non-loopback host. These are advisories, not validation errors; +intentional local fixtures can still use `domain = "localhost"` with an origin +such as `http://127.0.0.1:8080`. Loopback HTTP does not trigger the transport +warning, but the differing host still triggers the cookie-scope advisory. +Diagnostics run once per normalized app-config load or construction, not on +repeated validation of the same object. After validation, the shared configuration loader normalizes accepted values to the URL parser's serialized origin for output and stored config. For example, @@ -449,7 +457,8 @@ normalization, not silently discarded. Omission keeps old configuration files deserializable, not their previous URL authority behavior. Previously root-relative creative URLs followed the serving page's scheme, host, and port; the new default does not preserve those values. -`ts config validate` and `ts config push` warn when this field is omitted. +App-config loading, including `ts config validate`, `ts config push`, and +`ts config diff`, warns when this field is omitted. Set this field to the actual browser-facing Trusted Server endpoint, including its port when needed, rather than the backend `origin_url`. For direct local development on port 8080, for example, use `http://localhost:8080`. diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 232048079f..0473b30a67 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -34,6 +34,15 @@ including for GET rebuild navigation, even while the parent stamp remains unchanged. The pinned stamp still supplies legacy relative navigation and the endpoint fallback when no usable script origin exists. +Both publisher-page and creative-runtime script tags also carry the configured +origin in `data-ts-public-origin`. The runtime captures it once alongside the +script origin to recognize already-proxied resources on either origin, requiring +the exact `/first-party/proxy` path. This prevents a publisher-page render guard +from proxying a server-generated resource again when the bundle and configured +endpoint use different origins. The attribute does not change sign/rebuild +endpoint selection or the legacy parent stamp. Like the script source, it is not +authenticated against scripts that run before the runtime captures it. + ## How It Works ```mermaid 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 index 27683b3833..b9a7c69dcf 100644 --- 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 @@ -47,6 +47,13 @@ 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 @@ -57,11 +64,16 @@ flowchart TD 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] ``` -- Qualify the shared signed-URL builder's root-relative outputs. HTML attributes, - CSS, `srcset` and `/first-party/sign` then share one output rule. +- 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 @@ -89,9 +101,16 @@ 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 and exact-origin/exact-path -proxy recognition. Do not mistake another host's `/first-party/proxy` for a -Trusted Server proxy URL. +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 @@ -155,6 +174,12 @@ source selection and bundle hashes should remain unchanged. 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 diff --git a/scripts/smoke-axum.sh b/scripts/smoke-axum.sh index 103c04eb3a..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="" @@ -36,7 +37,7 @@ fi smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID # The positive case uses the final listener in the case sequence. -smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" "$((BASE_PORT + 4))" +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\"|" \ @@ -111,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 b97b7751e0..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="" @@ -45,7 +46,7 @@ fi smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID # The positive case uses the final listener in the case sequence. -smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" "$((BASE_PORT + 4))" +smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" "$POSITIVE_PORT" CLOUDFLARE_WORK="$WORKSPACE/cloudflare" mkdir -p "$CLOUDFLARE_WORK" @@ -188,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-fastly.sh b/scripts/smoke-fastly.sh index 15893e3478..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="" @@ -68,7 +69,7 @@ fi smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID # The positive case uses the final listener in the case sequence. -smoke_initialize_config "$FASTLY_PROJECT" "$WORKSPACE" "$ORIGIN_PORT" "$((BASE_PORT + 4))" +smoke_initialize_config "$FASTLY_PROJECT" "$WORKSPACE" "$ORIGIN_PORT" "$POSITIVE_PORT" run_case() { local case_name="$1" @@ -178,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 703cb1aedd..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 @@ -43,7 +44,7 @@ fi smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID # The positive case uses the final listener in the case sequence. -smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" "$((BASE_PORT + 4))" +smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" "$POSITIVE_PORT" SPIN_WORK="$WORKSPACE/spin" mkdir -p "$SPIN_WORK" @@ -135,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/trusted-server.example.toml b/trusted-server.example.toml index 504ed41b60..7b17751835 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -63,8 +63,8 @@ origin_url = "https://origin.example.com" # 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 fallback and coordinated upgrade/rollback warning before deployment: -# See public_origin under Publisher configuration: +# 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. From 8ed42ab8e0aff6c84287efe3ea3a76b102509ec9 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 7 Nov 2026 08:54:00 -0600 Subject: [PATCH 8/8] Account for listener metadata in EOF runtime goldens --- .../tests/html_eof_runtime.rs | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) 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 97c86844c7..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,6 +596,7 @@ 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()?; @@ -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))