diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc49c80b..9b1bdbc96 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 2f4329574..b32950045 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 c4bf7d990..73b223eb5 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 c931360f6..f5d968bda 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 df2781945..ba2ca162a 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 955ff235b..b83c92cf3 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 2291fce74..8f6e5f729 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 9b96dbd70..75377783d 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 dcc9e1506..21cfa50af 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 441828a18..a8efa76c6 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 dd0b35337..0789e9047 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 63a6d93c9..38aeafcb2 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 9e03f4dbd..94f1c7b83 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 03cc535c8..ffa7de7e9 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 e85b1d8d1..6348bc570 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 a505c03c5..42e29f78c 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 000000000..33c64fe7c --- /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 395553562..f1e21f135 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 b18d7d1a3..9daa8e28e 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 05dcd0e02..7cbfedb9b 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 000000000..56654f230 --- /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 000000000..dd0abf298 --- /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 48133fb72..101130f52 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 44105571d..d9cfe11a3 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 41c86a873..6e44b265c 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 9195138a4..cd6af3eba 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 d75958812..06addb926 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=...` | -| `