diff --git a/CHANGELOG.md b/CHANGELOG.md index e0261cf72..4dfa1aa8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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** — Integration configuration strings are no longer globally reinterpreted as JSON scalars. Operators upgrading should audit `[integrations.*]` settings and use native TOML/typed-config booleans and numbers (for example, `enabled = true`, not `enabled = "true"`); quoted numeric and boolean scalars now fail validation instead of silently converting. - **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. +- **Breaking** — Auction creative sanitization is now opt-in: the new `[auction].sanitize_creatives` defaults to `false` because unconditional sanitization blanked script-based creatives (the majority of programmatic display) while recording normal impressions. `[auction].rewrite_creatives` keeps its `true` default. The 1 MiB cap is now enforced on rewritten output as well as raw input and in every processing mode, rewriting fails closed on parser errors instead of emitting partial output, and `hb_cache_host`/`hb_cache_path` are emitted only for bids that supplied no creative — any bid carrying its own `adm` ships without them, so a processed or rejected creative can never be re-fetched raw from PBS Cache. Creative markup with no `` token now receives the click-guard runtime, and bidder `` elements are stripped whenever rewriting is enabled. The creative iframe sandbox no longer grants `allow-same-origin`, restoring origin isolation; rewritten-click recovery from the resulting opaque-origin iframe uses the GET `/first-party/proxy-rebuild` navigation fallback, now registered in every adapter, and dynamic resource signing inside those iframes is disabled pending [#982](https://github.com/IABTechLab/trusted-server/issues/982). Upgrading: binaries that predate `sanitize_creatives` reject a blob carrying it, so upgrade the binary first, then push the config. Rollback: non-default values (`sanitize_creatives = true`, `rewrite_creatives = false`) are serialized into the config blob and older binaries reject unknown fields — before rolling back to a binary that predates a field, restore its default, push the default-compatible blob, then roll back. - The SPA re-auction endpoint moved from `/__ts/page-bids` to `/_ts/page-bids`, joining every other internal route in the `/_ts/` namespace. The old path stays registered as a deprecated alias so already-loaded bundles keep serving ads, and responses on it carry a `Link: …; rel="deprecation"` header so remaining traffic is measurable from edge logs; removal is tracked in [#970](https://github.com/IABTechLab/trusted-server/issues/970). Two deployment notes: audit `[[handlers]]` for patterns broad enough to cover `/_ts` (for example `^/_ts`), which would put this browser-facing endpoint behind Basic Auth and return `401` to every visitor — scope them to `^/_ts/admin`; and prefer rolling forward over rolling back, since a server reverted past this release does not register the canonical path. In both cases the shipped client falls back to the deprecated alias, so the exposure is bounded until that alias is removed. ### Security @@ -24,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the default-true `[auction].rewrite_creatives` option. Setting it to `false` preserves mandatory creative sanitization across `POST /auction` and publisher SSAT/page-bids delivery while skipping first-party resource/click URL rewriting; it also skips creative TSJS injection on `POST /auction`. +- Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery. - Added Osano consent mirror integration docs and public enablement guidance. - Implemented basic authentication for configurable endpoint paths (#73) - Added integrations guide with example `testlight` integration diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 613ce3235..1bed830ac 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -364,7 +364,11 @@ fn named_routes() -> [NamedRoute; 13] { }, NamedRoute { path: "/first-party/proxy-rebuild", - primary_methods: &[Method::POST], + // GET serves the click guard's navigation fallback: the creative + // iframe is an opaque origin (sandbox without `allow-same-origin`), + // so its JSON POST is blocked by CORS and the guard navigates here + // for a 302 instead. + 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 4b15b4c6a..03caa3d11 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -91,6 +91,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 9f2e40796..644676fc5 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -517,6 +517,16 @@ fn build_router(state: &Arc) -> RouterService { handle_first_party_proxy_sign(&s.settings, &services, req).await }), ) + // GET serves the click guard's navigation fallback: the creative + // iframe is an opaque origin (sandbox without `allow-same-origin`), + // so its JSON POST is blocked by CORS and the guard navigates here + // for a 302 instead. + .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 d5eb98451..09e3ed324 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -230,6 +230,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 5258d3455..d6090c983 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -32,6 +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`] | +//! | GET | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | //! | 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 | @@ -1127,7 +1128,10 @@ const NAMED_ROUTES: &[NamedRoute] = &[ }, NamedRoute { path: "/first-party/proxy-rebuild", - primary_methods: &[Method::POST], + // GET serves the click guard's navigation fallback: the creative iframe + // is an opaque origin (sandbox without `allow-same-origin`), so its JSON + // POST is blocked by CORS and the guard navigates here for a 302 instead. + primary_methods: &[Method::GET, Method::POST], handler: NamedRouteHandler::FirstPartyProxyRebuild, }, ]; diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 68f2ddde6..960bafc41 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -156,7 +156,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { ("/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]), ] } @@ -626,7 +626,10 @@ 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 — GET serves the click guard's + // navigation fallback: the creative iframe is an opaque origin (sandbox + // without `allow-same-origin`), so its JSON POST is blocked by CORS and + // the guard navigates here for a 302 instead. let s = Arc::clone(&state); let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); @@ -640,6 +643,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( @@ -771,7 +775,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 2e1f0f6e5..2f7b1037e 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -517,6 +517,27 @@ 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() { + // The opaque-origin creative click guard recovers via GET navigation, so the + // route must be registered for GET and must not fall through to the + // publisher origin. This asserts routing only; the 302 and its rebuilt + // Location are covered by `proxy_rebuild_get_with_origin_form_uri_redirects` + // in the core crate, which can sign a real `tsclick`. + let router = test_router(); + let req = request_builder() + .method("GET") + .uri("/first-party/proxy-rebuild?tsclick=%2Ffirst-party%2Fclick%3Ftsurl%3Dhttps%253A%252F%252Fexample.com") + .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 diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs index 3a3b9dffd..39345137b 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -28,6 +28,7 @@ ids = ["trusted_server_config"] ids = ["trusted_server_secrets"] "#; const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES"; +const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES"; struct MigratedProject { directory: TempDir, @@ -42,7 +43,11 @@ fn migrated_legacy_project() -> MigratedProject { let mut document = LEGACY_CONFIG .parse::() .expect("should parse legacy integration config"); + // EdgeZero v0.0.4 environment overlays cannot create missing TOML leaves, + // so a migrated config must carry both creative-processing leaves for the + // corresponding environment variables to take effect. document["auction"]["rewrite_creatives"] = value(true); + document["auction"]["sanitize_creatives"] = value(false); fs::write(&config_path, document.to_string()).expect("should write migrated config"); fs::write(&manifest_path, MANIFEST).expect("should write test manifest"); MigratedProject { @@ -107,6 +112,49 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() { ); } +#[test] +fn migrated_legacy_config_applies_sanitize_creatives_environment_override() { + let project = migrated_legacy_project(); + let output = Command::new(env!("CARGO_BIN_EXE_ts")) + .args(["config", "push", "--adapter", "axum", "--manifest"]) + .arg(&project.manifest_path) + .arg("--app-config") + .arg(&project.config_path) + .args(["--yes", "--no-diff"]) + .current_dir(project.directory.path()) + .env(SANITIZE_ENV, "true") + .output() + .expect("should run ts config push"); + + assert!( + output.status.success(), + "valid boolean overlay should push successfully: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let local_store_path = project + .directory + .path() + .join(".edgezero/local-config-trusted_server_config.json"); + let local_store: serde_json::Value = serde_json::from_str( + &fs::read_to_string(local_store_path).expect("should read pushed local config"), + ) + .expect("should parse local config store"); + let envelope_json = local_store + .as_object() + .and_then(|entries| entries.values().next()) + .and_then(serde_json::Value::as_str) + .expect("should contain a blob envelope"); + let envelope: serde_json::Value = + serde_json::from_str(envelope_json).expect("should parse blob envelope"); + + assert_eq!( + envelope["data"]["auction"]["sanitize_creatives"], + serde_json::Value::Bool(true), + "pushed config should contain the sanitize environment override" + ); +} + #[test] fn migrated_legacy_config_default_rewrite_creatives_has_no_local_diff() { let project = migrated_legacy_project(); diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index 1ee2c66f8..1294423dc 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -136,7 +136,7 @@ When a request arrives at the `/auction` endpoint, it goes through the following ┌──────────────────────────────────────────────────────────────────────┐ │ 11. Transform to OpenRTB Response (mod.rs:274-322) │ │ - Build seatbid array (one per winning bid) │ -│ - Always sanitize creative HTML │ +│ - Sanitize creative HTML when enabled (opt-in) │ │ - Rewrite creative HTML when enabled (default) │ │ - Add orchestrator metadata (timing, strategy, bid count) │ └──────────────────────────────────────────────────────────────────────┘ @@ -249,12 +249,15 @@ The orchestrator collects all bids and creates an OpenRTB response: } ``` -Creative HTML is always sanitized. By default, each auction delivery path then -rewrites eligible URLs through the first-party proxy (`/first-party/proxy`). The -`POST /auction` response also injects the creative runtime; the publisher SSAT -inline path uses absolute first-party URLs without injecting that bundle. Setting -`[auction].rewrite_creatives = false` skips rewriting in both paths and runtime -injection on `POST /auction`. +With `[auction].sanitize_creatives = true` (opt-in, default `false`), +executable markup is stripped with its inner content before delivery. With +`[auction].rewrite_creatives = true` (the default), each auction delivery path +rewrites eligible URLs through the first-party proxy (`/first-party/proxy`) and +removes bidder `` elements. The `POST /auction` response also injects the +creative runtime; the publisher SSAT inline path uses absolute first-party URLs +without injecting that bundle. With both disabled, the creative ships exactly +as the bidder returned it. In every mode, creatives over the 1 MiB cap are +rejected. ## Route Registration & Endpoints @@ -268,7 +271,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()` | Re-sign mutated click URLs (GET 302s for the opaque-origin click guard) | 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 | @@ -385,8 +388,7 @@ The `/auction` endpoint is the primary entry point for auctions: **Key Transformations:** - `adUnits[].code` → `seatbid[].bid[].impid` (slot identifier) - `mediaTypes.banner.sizes` → evaluated by providers, winning size in `bid.w` and `bid.h` -- Creative HTML is always sanitized, then rewritten to use `/first-party/proxy` URLs by default -- `[auction].rewrite_creatives = false` skips rewriting in both delivery paths and `POST /auction` creative runtime injection, not sanitization +- Creative HTML: `[auction].sanitize_creatives = true` (opt-in) strips executable markup; `[auction].rewrite_creatives = true` (default) rewrites eligible URLs to `/first-party/proxy` in both delivery paths (with creative runtime injection on `POST /auction` only); with both disabled the creative ships as the bidder returned it - Multiple bids per slot become separate `seatbid` entries - Orchestrator metadata added in `ext.orchestrator` diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index e6f620e5b..0a656a8c6 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -217,8 +217,15 @@ pub fn convert_tsjs_to_auction_request( /// Convert `OrchestrationResult` to `OpenRTB` response format. /// -/// Always sanitizes creative HTML in the `adm` field and optionally rewrites it -/// according to the auction configuration. +/// Creative HTML in the `adm` field is optionally sanitized and optionally +/// rewritten according to the auction configuration +/// ([`AuctionConfig::sanitize_creatives`], opt-in, and +/// [`AuctionConfig::rewrite_creatives`], default-on); with both disabled the +/// creative ships exactly as the bidder returned it, subject to the 1 MiB +/// per-creative cap. +/// +/// [`AuctionConfig::sanitize_creatives`]: crate::auction_config_types::AuctionConfig::sanitize_creatives +/// [`AuctionConfig::rewrite_creatives`]: crate::auction_config_types::AuctionConfig::rewrite_creatives /// /// # Errors /// @@ -252,15 +259,18 @@ pub fn convert_to_openrtb_response( let width = to_openrtb_i32(bid.width, "width", &bid_context); let height = to_openrtb_i32(bid.height, "height", &bid_context); - // Process creative HTML if present — always sanitize dangerous markup first. + // Process creative HTML if present. Sanitization is opt-in and + // rewriting is on by default; with both disabled the creative ships + // exactly as the bidder returned it. let creative_html = if let Some(ref raw_creative) = bid.creative { let processed = creative::process_auction_creative(settings, raw_creative); log::debug!( - "Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, output {} bytes)", + "Processed creative for auction {} slot {} bidder {} (sanitize {}, rewrite {}, raw {} bytes, output {} bytes)", auction_request.id, slot_id, bid.bidder, + settings.auction.sanitize_creatives, rewrite_creatives, raw_creative.len(), processed.len() @@ -930,7 +940,18 @@ mod tests { assert_eq!(bid["id"], json!("appnexus-div-gpt-top")); assert_eq!(bid["impid"], json!("div-gpt-top")); assert_eq!(bid["price"], json!(2.75)); - assert_eq!(bid["adm"], json!("
Ad
")); + // Rewriting is on by default, and a body-less fragment still receives + // the creative runtime (prepended), so the markup is carried rather + // than returned verbatim. + let adm = bid["adm"].as_str().expect("should serialize adm"); + assert!( + adm.contains("
Ad
"), + "should carry the creative: {adm}" + ); + assert!( + adm.contains("/static/tsjs=tsjs-unified.min.js"), + "should inject the creative runtime into a body-less fragment: {adm}" + ); assert_eq!(bid["crid"], json!("appnexus-creative")); assert_eq!(bid["w"], json!(300)); assert_eq!(bid["h"], json!(250)); @@ -951,8 +972,10 @@ mod tests { } #[test] - fn convert_to_openrtb_response_rewrites_sanitized_creative_by_default() { - let settings = make_settings(); + fn convert_to_openrtb_response_rewrites_sanitized_creative_when_enabled() { + let mut settings = make_settings(); + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = true; let auction_request = make_auction_request(); let result = make_result(make_complete_creative_bid()); @@ -999,12 +1022,88 @@ mod tests { } #[test] - fn convert_to_openrtb_response_can_skip_rewriting_but_not_sanitization() { + fn convert_to_openrtb_response_can_skip_sanitization_when_disabled() { + // Sanitization strips every executable element with its inner content, which + // destroys script-based creatives (the majority of programmatic display). + // Publishers whose creatives render in a foreign-origin frame — where the + // markup cannot reach the publisher origin — can opt out and deliver the + // creative exactly as the bidder returned it. let mut settings = make_settings(); + settings.auction.sanitize_creatives = false; settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let result = make_result(make_complete_creative_bid()); + let original = make_complete_creative_bid() + .creative + .expect("should have a creative fixture"); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with sanitization disabled"); + let adm = response_adm(response); + + assert_eq!( + adm, original, + "should deliver the creative byte-for-byte as the bidder returned it" + ); + } + + #[test] + fn convert_to_openrtb_response_rewrites_raw_markup_without_sanitizing() { + // The fourth mode: rewriting enabled while sanitization stays off. The + // rewriter converts eligible resource/click URLs on the raw bidder + // markup and preserves executable content. + let mut settings = make_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = true; + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with rewriting only"); + let adm = response_adm(response); + + assert!( + adm.contains("/first-party/proxy?tsurl="), + "should rewrite accepted resource URLs: {adm}" + ); + assert!( + adm.contains("/first-party/click?tsurl="), + "should rewrite accepted click URLs: {adm}" + ); + assert!( + adm.contains("auction-script-marker"), + "should preserve script content when sanitization is disabled: {adm}" + ); + assert!( + adm.contains("auction-handler-marker"), + "should preserve event handlers when sanitization is disabled: {adm}" + ); + } + + #[test] + fn sanitize_creatives_defaults_to_disabled() { + let config = crate::auction_config_types::AuctionConfig::default(); + assert!( + !config.sanitize_creatives, + "sanitization is opt-in: it blanks script-based creatives" + ); + assert!( + config.rewrite_creatives, + "creative URL rewriting stays enabled by default" + ); + } + + #[test] + fn convert_to_openrtb_response_can_skip_rewriting_while_sanitizing() { + // The two controls are independent: sanitization can stay on while URL + // rewriting is off. + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; + settings.auction.sanitize_creatives = true; + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) .expect("should convert creative with rewriting disabled"); let adm = response_adm(response); @@ -1171,10 +1270,12 @@ mod tests { "should preserve top slot impid" ); assert_eq!(top_bid["price"], json!(2.75), "should preserve top price"); - assert_eq!( - top_bid["adm"], - json!("
Ad
"), - "should preserve top creative" + assert!( + top_bid["adm"] + .as_str() + .is_some_and(|adm| adm.contains("
Ad
")), + "should preserve top creative: {}", + top_bid["adm"] ); let sidebar_seatbid = seatbids @@ -1202,10 +1303,12 @@ mod tests { json!(1.25), "should preserve sidebar price" ); - assert_eq!( - sidebar_bid["adm"], - json!("
Sidebar
"), - "should preserve sidebar creative" + assert!( + sidebar_bid["adm"] + .as_str() + .is_some_and(|adm| adm.contains("
Sidebar
")), + "should preserve sidebar creative: {}", + sidebar_bid["adm"] ); assert_eq!( json["ext"]["orchestrator"]["total_bids"], diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 4a143b223..c16a839e8 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2102,6 +2102,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, + sanitize_creatives: true, rewrite_creatives: true, providers: vec![], mediator: None, diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index c6c237e59..27b62b11b 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -11,7 +11,24 @@ pub struct AuctionConfig { #[serde(default)] pub enabled: bool, - /// Rewrite sanitized winning-bid creative HTML to first-party endpoints. + /// Strip executable markup from winning-bid creative HTML before delivery. + /// + /// Sanitization removes `script`/`object`/`embed`/`form`/etc. **with their inner + /// content**, which blanks script-based creatives — the majority of programmatic + /// display. It is the primary defence when the creative renders in a context that + /// shares the publisher's origin. + /// + /// Disable only when creatives render in a foreign-origin frame (for example the + /// Prebid Universal Creative inside the ad server's iframe), where the markup + /// cannot reach the publisher origin. Defaults to disabled. + #[serde( + default = "default_sanitize_creatives", + skip_serializing_if = "is_default_sanitize_creatives" + )] + pub sanitize_creatives: bool, + + /// Rewrite winning-bid creative HTML to first-party endpoints (applied + /// after sanitization when [`Self::sanitize_creatives`] is enabled). /// /// The default must stay omitted from serialized config blobs: older /// [`AuctionConfig`] schemas reject unknown fields during binary rollback. @@ -53,6 +70,7 @@ impl Default for AuctionConfig { fn default() -> Self { Self { enabled: false, + sanitize_creatives: default_sanitize_creatives(), rewrite_creatives: default_rewrite_creatives(), providers: Vec::new(), mediator: None, @@ -67,6 +85,10 @@ fn default_timeout() -> u32 { 2000 } +fn default_sanitize_creatives() -> bool { + false +} + fn default_rewrite_creatives() -> bool { true } @@ -76,6 +98,10 @@ fn is_default_rewrite_creatives(value: &bool) -> bool { *value == default_rewrite_creatives() } +fn is_default_sanitize_creatives(value: &bool) -> bool { + *value == default_sanitize_creatives() +} + fn default_creative_store() -> String { "creative_store".to_owned() } @@ -107,13 +133,17 @@ mod tests { use super::*; #[test] - fn rewrite_creatives_defaults_to_true() { + fn creative_processing_defaults() { let config: AuctionConfig = serde_json::from_value(serde_json::json!({})).expect("should deserialize defaults"); assert!( config.rewrite_creatives, - "should enable creative rewriting by default" + "creative rewriting stays enabled by default: existing deployments keep first-party proxying" + ); + assert!( + !config.sanitize_creatives, + "creative sanitization is opt-in: it strips executable markup with its content" ); } @@ -142,4 +172,31 @@ mod tests { "should preserve an explicit rewrite opt-out" ); } + + #[test] + fn default_sanitize_creatives_is_not_serialized() { + let serialized = + serde_json::to_value(AuctionConfig::default()).expect("should serialize defaults"); + + assert!( + serialized.get("sanitize_creatives").is_none(), + "should omit the default sanitize setting" + ); + } + + #[test] + fn enabled_sanitize_creatives_is_serialized() { + let config = AuctionConfig { + sanitize_creatives: true, + ..AuctionConfig::default() + }; + let serialized = + serde_json::to_value(config).expect("should serialize enabled sanitization"); + + assert_eq!( + serialized.get("sanitize_creatives"), + Some(&serde_json::Value::Bool(true)), + "should preserve an explicit sanitize opt-in" + ); + } } diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs index 527b9869b..b2dd8c62a 100644 --- a/crates/trusted-server-core/src/creative.rs +++ b/crates/trusted-server-core/src/creative.rs @@ -506,10 +506,15 @@ pub fn sanitize_creative_html(markup: &str) -> String { String::from_utf8(out).unwrap_or_default() } -/// Sanitize auction creative HTML, then optionally rewrite it to first-party endpoints. +/// Optionally sanitize auction creative HTML, then optionally rewrite it to +/// first-party endpoints. /// -/// Sanitization is mandatory in both modes. Rewriting is controlled by -/// [`crate::auction_config_types::AuctionConfig::rewrite_creatives`]. +/// Sanitization is controlled by +/// [`crate::auction_config_types::AuctionConfig::sanitize_creatives`] and +/// rewriting by +/// [`crate::auction_config_types::AuctionConfig::rewrite_creatives`]. With both +/// disabled the creative is returned exactly as the bidder sent it. In every +/// mode, input over the 1 MiB per-creative cap is rejected (empty string). #[must_use] pub(crate) fn process_auction_creative(settings: &Settings, raw: &str) -> String { process_auction_creative_with_rewriter(settings, raw, |sanitized| { @@ -519,9 +524,10 @@ pub(crate) fn process_auction_creative(settings: &Settings, raw: &str) -> String /// Process an inline auction creative rendered from a foreign-origin document. /// -/// Sanitization is mandatory. When auction creative rewriting is enabled, proxy -/// and click URLs are emitted as absolute URLs against `base_origin` without -/// injecting the creative TSJS bundle. +/// Applies the same opt-in sanitization as [`process_auction_creative`]. When +/// auction creative rewriting is enabled, proxy and click URLs are emitted as +/// absolute URLs against `base_origin` without injecting the creative TSJS +/// bundle. #[must_use] pub(crate) fn process_inline_auction_creative( settings: &Settings, @@ -538,7 +544,24 @@ fn process_auction_creative_with_rewriter( raw: &str, rewrite: impl FnOnce(&str) -> String, ) -> String { - let sanitized = sanitize_creative_html(raw); + // The per-creative size cap is a delivery invariant, not a sanitizer + // implementation detail: it must hold in every processing mode, including + // full pass-through, so oversized markup never reaches rewriting, JSON + // serialization, or the client. Fail closed with an empty string, matching + // the sanitizer's own oversized-input behaviour. + if raw.len() > MAX_CREATIVE_SIZE { + log::warn!( + "process_auction_creative: creative of {} bytes exceeds {} byte cap; rejecting", + raw.len(), + MAX_CREATIVE_SIZE + ); + return String::new(); + } + let sanitized = if settings.auction.sanitize_creatives { + sanitize_creative_html(raw) + } else { + raw.to_owned() + }; if settings.auction.rewrite_creatives { rewrite(&sanitized) } else { @@ -629,13 +652,32 @@ fn rewrite_creative_html_impl( ) -> String { // No size parsing needed now; all absolute/protocol-relative URLs are proxied uniformly. let mut out = Vec::with_capacity(markup.len() + 64); - let injected_ts_creative = std::cell::Cell::new(false); + // Shared with the `body` handler through an `Rc` so the outcome is readable + // here after rewriting: cloning a bare `Cell` would hand the handler an + // independent copy and always report "not injected". + let injected_ts_creative = std::rc::Rc::new(std::cell::Cell::new(false)); + // Rewriting amplifies: every short URL becomes a signed proxy/click URL and + // anchors gain a `data-tsclick` copy, so an input comfortably under the cap + // can expand well past it. Bound the OUTPUT too, and stop accumulating once + // the limit trips, so a bidder cannot drive unbounded allocation in the + // WASM runtime by packing a creative with URL-bearing elements. + let overflowed = std::cell::Cell::new(false); let mut rewriter = HtmlRewriter::new( HtmlSettings { element_content_handlers: vec![ + // Remove unconditionally: a bidder-supplied base URL + // rebases the root-relative `/first-party/…` and `/static/tsjs=…` + // URLs this pass emits onto an attacker-chosen origin, hijacking + // proxy/click mediation and leaking signed URL data. The + // sanitizer also strips , but rewriting must not depend on + // sanitization, which is independently optional. + element!("base", |el| { + el.remove(); + Ok(()) + }), // Inject unified tsjs bundle at the top of body once element!("body", { - let injected = injected_ts_creative.clone(); + let injected = std::rc::Rc::clone(&injected_ts_creative); move |el| { if inject_tsjs && !injected.get() { let script_tag = tsjs::tsjs_unified_script_tag(); @@ -809,12 +851,62 @@ fn rewrite_creative_html_impl( ], ..HtmlSettings::default() }, - |c: &[u8]| out.extend_from_slice(c), + |c: &[u8]| { + if overflowed.get() { + return; + } + if out.len() + c.len() > MAX_CREATIVE_SIZE { + overflowed.set(true); + out.clear(); + out.shrink_to_fit(); + return; + } + out.extend_from_slice(c); + }, ); - let _ = rewriter.write(markup.as_bytes()); - let _ = rewriter.end(); - String::from_utf8(out).unwrap_or_else(|_| markup.to_owned()) + // Fail closed on parser or output-limit failures, matching the sanitizer: + // a partially rewritten document has an unknown mix of mediated and direct + // URLs, and truncated markup can reopen tags the rewriter had closed. + // Do not call end() after a failed write — lol_html's rewriter is in an + // error state and may emit garbage. + if rewriter.write(markup.as_bytes()).is_err() || rewriter.end().is_err() { + log::warn!("rewrite_creative_html: html rewrite failed; rejecting creative"); + return String::new(); + } + if overflowed.get() { + log::warn!( + "rewrite_creative_html: rewritten creative exceeds {} byte cap; rejecting", + MAX_CREATIVE_SIZE + ); + return String::new(); + } + + let mut rewritten = match String::from_utf8(out) { + Ok(rewritten) => rewritten, + Err(_) => { + log::warn!("rewrite_creative_html: rewriter emitted non-UTF-8 output; rejecting"); + return String::new(); + } + }; + + // Creative `adm` is frequently a bare fragment (``) + // with no `` token for the handler above to match, and lol_html does + // not synthesize one. Without this fallback such fragments would ship + // without the click guard, leaving rewritten links unmediated once bidder + // script mutates them. + if inject_tsjs && !injected_ts_creative.get() { + rewritten.insert_str(0, &tsjs::tsjs_unified_script_tag()); + if rewritten.len() > MAX_CREATIVE_SIZE { + log::warn!( + "rewrite_creative_html: creative exceeds {} byte cap after runtime injection; rejecting", + MAX_CREATIVE_SIZE + ); + return String::new(); + } + } + + rewritten } /// Stream processor for creative HTML that rewrites URLs to first-party proxy. @@ -1543,8 +1635,10 @@ mod tests { } #[test] - fn process_auction_creative_rewrites_after_sanitizing_by_default() { - let settings = crate::test_support::tests::create_test_settings(); + fn process_auction_creative_rewrites_after_sanitizing_when_enabled() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = true; let html = r#""#; let processed = process_auction_creative(&settings, html); @@ -1564,8 +1658,9 @@ mod tests { } #[test] - fn process_auction_creative_can_skip_rewriting_but_not_sanitization() { + fn process_auction_creative_can_skip_rewriting_while_sanitizing() { let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = true; settings.auction.rewrite_creatives = false; let html = r#""#; @@ -1589,6 +1684,172 @@ mod tests { ); } + #[test] + fn process_auction_creative_passes_through_byte_for_byte_when_disabled() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = false; + let html = r#"
"#; + + let processed = process_auction_creative(&settings, html); + + assert_eq!( + processed, html, + "should return the creative exactly as the bidder sent it when both controls are disabled" + ); + } + + #[test] + fn process_auction_creative_rewrites_raw_markup_without_sanitizing() { + // The fourth mode: rewriting enabled, sanitization disabled. Eligible + // URLs are rewritten while executable markup is preserved. + let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = true; + let html = r#"
x
"#; + + let processed = process_auction_creative(&settings, html); + + assert!( + processed.contains("/first-party/proxy?tsurl="), + "should rewrite accepted resource URLs: {processed}" + ); + assert!( + processed.contains("marker"), + "should preserve script content when sanitization is disabled: {processed}" + ); + assert!( + processed.contains("onclick"), + "should preserve event handlers when sanitization is disabled: {processed}" + ); + } + + #[test] + fn rewrite_only_mode_strips_base_elements() { + // Rewriting emits root-relative `/first-party/…` and `/static/tsjs=…` + // URLs, so a bidder-supplied would rebase them onto a foreign + // origin. The rewriter must remove itself: sanitization also + // strips it, but is independently optional. + let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = true; + let html = r#"x"#; + + let processed = process_auction_creative(&settings, html); + + assert!( + !processed.contains(" element, head or body: {processed}" + ); + assert!( + processed.contains("/first-party/proxy?tsurl="), + "should still rewrite resource URLs: {processed}" + ); + } + + #[test] + fn rewrite_injects_runtime_into_body_less_fragment() { + // Bidder `adm` is commonly a bare fragment with no token, and + // lol_html does not synthesize one. Without the runtime the click guard + // never installs, so rewritten links lose first-party mediation as soon + // as surviving bidder script mutates them. + let settings = crate::test_support::tests::create_test_settings(); + let fragment = r#"x"#; + + let out = rewrite_creative_html(&settings, fragment); + + assert!( + out.contains("/static/tsjs=tsjs-unified.min.js"), + "should inject the creative runtime without a body token: {out}" + ); + assert_eq!( + out.matches("/static/tsjs=tsjs-unified.min.js").count(), + 1, + "should inject exactly once: {out}" + ); + assert!( + out.contains("/first-party/click?tsurl="), + "should still rewrite click URLs: {out}" + ); + } + + #[test] + fn inline_rewrite_does_not_inject_runtime_into_fragment() { + // The foreign-origin inline path deliberately omits the bundle; the + // body-less fallback must not reintroduce it there. + let settings = crate::test_support::tests::create_test_settings(); + let fragment = r#"x"#; + + let out = + rewrite_inline_creative_html(&settings, "https://news.publisher.example", fragment); + + assert!( + !out.contains("/static/tsjs="), + "inline rewriting must not inject the bundle: {out}" + ); + } + + #[test] + fn rewrite_rejects_output_exceeding_the_cap() { + // Rewriting amplifies: each short URL becomes a signed proxy/click URL + // and anchors gain a data-tsclick copy. An input under the cap can + // therefore expand past it, so the OUTPUT is bounded too. + let settings = crate::test_support::tests::create_test_settings(); + let anchor = r#"x"#; + let repeats = (super::MAX_CREATIVE_SIZE / anchor.len()) / 2; + let input = anchor.repeat(repeats); + assert!( + input.len() < super::MAX_CREATIVE_SIZE, + "test input must start under the cap" + ); + + let out = rewrite_creative_html(&settings, &input); + + assert!( + out.is_empty(), + "should reject a creative whose rewritten output exceeds the cap (got {} bytes)", + out.len() + ); + } + + #[test] + fn inline_rewrite_strips_base_elements() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = true; + let html = r#""#; + + let processed = super::process_inline_auction_creative( + &settings, + "https://news.publisher.example", + html, + ); + + assert!( + !processed.contains(" from inline creatives too: {processed}" + ); + } + + #[test] + fn process_auction_creative_rejects_oversized_markup_in_every_mode() { + // The 1 MiB per-creative cap is a delivery invariant independent of the + // sanitize/rewrite flags: oversized markup fails closed everywhere. + let oversized = format!("
{}
", "a".repeat(super::MAX_CREATIVE_SIZE + 1)); + for (sanitize, rewrite) in [(false, false), (true, false), (false, true), (true, true)] { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = sanitize; + settings.auction.rewrite_creatives = rewrite; + + let processed = process_auction_creative(&settings, &oversized); + + assert!( + processed.is_empty(), + "should reject oversized creative with sanitize={sanitize} rewrite={rewrite}" + ); + } + } + #[test] fn to_abs_additional_cases() { let settings = crate::test_support::tests::create_test_settings(); diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 38c90c350..274a5ab27 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -657,6 +657,27 @@ fn finalize_proxied_response_streaming( beresp } +/// Allow proxied assets to load into opaque-origin creative frames. +/// +/// Creative iframes are sandboxed without `allow-same-origin`, so their requests +/// to `/first-party/proxy` are cross-origin with `Origin: null`. CORS-mode +/// subresources — ES modules, `crossorigin` fonts, `fetch`/XHR — are blocked +/// without an allow header, even though plain ``/`".to_string(), + "script-only", + ); + // Oversized creative: rejected by the cap in every mode. + assert_no_render_source( + &test_settings(), + format!("
{}
", "a".repeat(1024 * 1024 + 1)), + "oversized", + ); + // An explicit empty `adm` is a supplied creative, not an absent one: + // classifying it as absent would re-enable the raw cache fallback. + assert_no_render_source(&test_settings(), String::new(), "explicit-empty"); + } + + #[test] + fn build_bid_map_keeps_cache_fallback_for_absent_creatives() { + // A bid with no supplied creative is the legitimate PBS Cache case: + // the coordinates are the only render source. + let mut winning_bids = HashMap::new(); + let mut bid = cached_bid_with_creative(""); + bid.creative = None; + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map( + &winning_bids, + PriceGranularity::Dense, + &test_settings(), + "", + false, + ); + let obj = map + .get("atf_sidebar_ad") + .and_then(|v| v.as_object()) + .expect("should have a bid entry"); + + assert_eq!( + obj.get("hb_cache_host").and_then(|v| v.as_str()), + Some("prebid-cache.example.com"), + "absent creative should keep hb_cache_host" + ); + assert_eq!( + obj.get("hb_cache_path").and_then(|v| v.as_str()), + Some("/cache"), + "absent creative should keep hb_cache_path" + ); + } + #[test] fn build_bid_map_rewrites_inline_adm_to_absolute_first_party_urls() { // The inline `adm` is rendered by the Prebid Universal Creative inside @@ -7760,6 +7883,7 @@ mod tests { // root-relative `/first-party/proxy` would resolve against GAM and 404. // The tsjs bundle must NOT be injected into that foreign-origin iframe. let mut settings = test_settings(); + settings.auction.rewrite_creatives = true; settings.publisher.domain = "example.com".to_string(); let mut winning_bids = HashMap::new(); @@ -7809,6 +7933,7 @@ mod tests { // origin the visitor is on (here an HTTP dev host with a port), not the // configured publisher domain. let mut settings = test_settings(); + settings.auction.rewrite_creatives = true; settings.publisher.domain = "example.com".to_string(); let mut winning_bids = HashMap::new(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index e193a70aa..6f623a630 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3914,7 +3914,7 @@ origin_host_header_overide = "www.example.com""#, } #[test] - fn test_auction_rewrite_creatives_defaults_to_true_when_omitted() { + fn test_auction_creative_processing_defaults_when_omitted() { let toml_str = crate_test_settings_str() + r#" [auction] @@ -3926,7 +3926,11 @@ origin_host_header_overide = "www.example.com""#, assert!( settings.auction.rewrite_creatives, - "should preserve creative rewriting when the setting is omitted" + "creative rewriting stays enabled when the setting is omitted" + ); + assert!( + !settings.auction.sanitize_creatives, + "creative sanitization is opt-in when the setting is omitted" ); } 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 new file mode 100644 index 000000000..517aeb403 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from "@playwright/test"; +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 — is only observable in a real +// browser. +const CREATIVE_SANDBOX_TOKENS = [ + "allow-forms", + "allow-popups", + "allow-popups-to-escape-sandbox", + "allow-scripts", + "allow-top-navigation-by-user-activation", +].join(" "); + +// 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. The anchor carries a root-relative signed click exactly as the +// server-side rewriter emits it. +function creativeDocument(origin: string, bundleUrl: string): string { + const signedClick = + "/first-party/click?tsurl=https%3A%2F%2Fadvertiser.example%2Flanding&foo=1&tstoken=browser-test-token"; + return ` + + + + + + + ad + +`; +} + +test.describe("Sandboxed creative iframe", () => { + test("recovers a mutated click through the GET rebuild fallback", async ({ + page, + }) => { + await page.goto(runtimeUrl("/"), { waitUntil: "domcontentloaded" }); + + // Prefer 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"); + + const rebuildRequest = page.waitForRequest( + (request) => request.url().includes("/first-party/proxy-rebuild"), + { 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(new URL(runtimeUrl("/")).origin, bundleUrl), + }, + ); + + 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) => { + element.setAttribute( + "href", + "https://advertiser.example/landing?foo=1&bar=2", + ); + }); + + await link.click({ force: true }); + + const request = await rebuildRequest; + expect(request.url()).toContain("tsclick="); + expect(decodeURIComponent(request.url())).toContain("bar"); + }); +}); diff --git a/crates/trusted-server-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts index ee08ef288..586343402 100644 --- a/crates/trusted-server-js/lib/src/core/render.ts +++ b/crates/trusted-server-js/lib/src/core/render.ts @@ -7,15 +7,21 @@ import NORMALIZE_CSS from './styles/normalize.css?inline'; import IFRAME_TEMPLATE from './templates/iframe.html?raw'; // Sandbox permissions granted to creative iframes. +// // Ad creatives routinely contain scripts for tracking, click handling, and -// viewability measurement, so allow-scripts and allow-same-origin are required -// for creatives to render correctly. Server-side sanitization is the primary -// defense against malicious markup; the sandbox provides defense-in-depth. +// viewability measurement, so `allow-scripts` is required for them to render. +// +// `allow-same-origin` is deliberately excluded: combined with `allow-scripts` on +// srcdoc (or first-party src) content, that pair effectively removes the sandbox's +// origin isolation and would let SSP-provided markup run with the publisher +// origin's privileges — cookies, storage, and same-origin fetches. The origin +// boundary must not depend on server-side sanitization, which is optional +// (`auction.sanitize_creatives`) and cannot run at all for renderer-based bids. +// Matches APS_RENDERER_SANDBOX and ADM_IFRAME_SANDBOX, which already omit it. const CREATIVE_SANDBOX_TOKENS = [ 'allow-forms', 'allow-popups', 'allow-popups-to-escape-sandbox', - 'allow-same-origin', 'allow-scripts', 'allow-top-navigation-by-user-activation', ] as const; @@ -26,8 +32,10 @@ export type AcceptedCreativeHtml = { kind: 'accepted'; originalLength: number; sanitizedHtml: string; - // Always equal to originalLength: the client validates type/emptiness only; - // server-side sanitization has already run before adm reaches this function. + // Always equal to originalLength: the client validates type/emptiness only + // and never removes content. Server-side sanitization is opt-in + // (`auction.sanitize_creatives`); the origin boundary for this markup is the + // iframe sandbox, not this function. // Retained so both union members of SanitizeCreativeHtmlResult have consistent fields. sanitizedLength: number; // Always 0 for the same reason — no content is removed client-side. @@ -53,9 +61,12 @@ function normalizeId(raw: string): string { } // Validate the untrusted creative fragment before embedding it in the sandboxed iframe. -// Dangerous markup is stripped server-side before adm reaches the client; this function -// only guards against type errors and empty payloads. As a result, sanitizedLength always -// equals originalLength and removedCount is always 0 for accepted creatives — these fields +// This is validation-only, not sanitization: it guards against type errors and empty +// payloads and never removes content. Server-side stripping of executable markup is +// opt-in (`auction.sanitize_creatives`), so the adm arriving here may be raw bidder +// markup — the origin boundary is the iframe sandbox (no `allow-same-origin`), which +// does not depend on any sanitization having run. sanitizedLength always equals +// originalLength and removedCount is always 0 for accepted creatives — these fields // exist for structural consistency with the shared result type but carry no signal here. export function sanitizeCreativeHtml(creativeHtml: unknown): SanitizeCreativeHtmlResult { if (typeof creativeHtml !== 'string') { @@ -163,7 +174,9 @@ export function renderAllAdUnits(): void { type IframeOptions = { name?: string; title?: string; width?: number; height?: number }; -// Construct a sandboxed iframe sized for sanitized, non-executable creative HTML. +// Construct a sandboxed iframe for creative HTML. The markup may be raw bidder +// output (server-side sanitization is opt-in); the sandbox's origin isolation, +// not any sanitization, is the security boundary. export function createAdIframe( container: HTMLElement, opts: IframeOptions = {} @@ -205,10 +218,29 @@ export function createAdIframe( return iframe; } -// Build a complete HTML document for a sanitized creative fragment, suitable for iframe.srcdoc. +// Origin the creative runtime resolves root-relative first-party URLs against. +// +// 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. +// +// Only an exact `scheme://host[:port]` shape is emitted, so the value cannot +// break out of the quoted string it is written into. +function trustedCreativeOrigin(): string { + try { + const origin = location.origin; + if (/^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(origin)) return origin; + } catch { + // fall through to an empty stamp; the runtime degrades to document.baseURI + } + return ''; +} + +// Build a complete HTML document for a creative fragment, suitable for iframe.srcdoc. export function buildCreativeDocument(creativeHtml: string): string { - return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', () => NORMALIZE_CSS).replace( - '%CREATIVE_HTML%', - () => creativeHtml - ); + return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', () => NORMALIZE_CSS) + .replace('%TRUSTED_ORIGIN%', () => trustedCreativeOrigin()) + .replace('%CREATIVE_HTML%', () => creativeHtml); } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index e39300a14..5ac19ab6c 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -79,7 +79,9 @@ export function requestAds( } } -// Render a creative by writing sanitized, non-executable HTML into a sandboxed iframe. +// Render a creative by writing its HTML into a sandboxed iframe. The markup may +// be raw bidder output (server-side sanitization is opt-in); the sandbox's +// origin isolation is the security boundary. function renderCreativeInline({ slotId, creativeHtml, 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 55af0d5f3..86011ed46 100644 --- a/crates/trusted-server-js/lib/src/core/templates/iframe.html +++ b/crates/trusted-server-js/lib/src/core/templates/iframe.html @@ -2,6 +2,9 @@ + %CREATIVE_HTML% 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..61b55d56c 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -2,12 +2,22 @@ import { log } from '../../core/log'; import { creativeGlobal } from '../../shared/globals'; import { delay, queueTask } from '../../shared/async'; +import { hasOpaqueOrigin, TRUSTED_BASE_URL } from '../../shared/origin'; import { createMutationScheduler } from '../../shared/scheduler'; type AnchorLike = HTMLAnchorElement | HTMLAreaElement; type Canon = { base: string; params: Record }; type Diff = { add: Record; del: string[] }; +// Rebuild URLs already written to an anchor's href by an earlier repair pass +// (the opaque-origin GET fallback). They are not `/first-party/click` URLs, so +// they cannot be canonicalized and deliberately never replace the canonical +// `data-tsclick`. Without remembering them, a later click would canonicalize +// the fallback against the original signed click, fail the base comparison, and +// navigate the pre-mutation URL — silently dropping the mutation the fallback +// exists to carry. +const pendingRebuilds = new WeakMap(); + // Allow query/localStorage flag to crank logging when debugging creatives. function enableDebugFromEnv(): void { try { @@ -34,9 +44,12 @@ function parseQuery(qs: string): Record { } // Decode a signed /first-party/click URL back into its clear destination + params. +// URLs resolve against the pinned trusted base, not `location.href`: inside the +// sandboxed `srcdoc` creative iframe `location.href` is `about:srcdoc`, which +// `new URL` rejects as a base for the root-relative URLs the rewriter emits. function canonFromFirstPartyClick(url: string): Canon | null { try { - const u = new URL(url, location.href); + const u = new URL(url, TRUSTED_BASE_URL); if (!(u.pathname === '/first-party/click' || u.pathname.startsWith('/first-party/click'))) return null; const p = parseQuery(u.search); @@ -55,7 +68,7 @@ function canonFromAnyHref(href: string): Canon | null { const fp = canonFromFirstPartyClick(href); if (fp) return fp; try { - const u = new URL(href, location.href); + const u = new URL(href, TRUSTED_BASE_URL); const params = parseQuery(u.search); u.search = ''; u.hash = ''; @@ -68,8 +81,8 @@ function canonFromAnyHref(href: string): Canon | null { // Compare two URLs but ignore http↔https differences that creatives often introduce. function sameBaseIgnoreScheme(aBase: string, bBase: string): boolean { try { - const au = new URL(aBase, location.href); - const bu = new URL(bBase, location.href); + const au = new URL(aBase, TRUSTED_BASE_URL); + const bu = new URL(bBase, TRUSTED_BASE_URL); return au.hostname === bu.hostname && au.pathname === bu.pathname; } catch { return aBase === bBase; @@ -145,6 +158,11 @@ function buildProxyRebuildUrl(tsClickStr: string, diff: Diff): string { } // Call the proxy-rebuild endpoint so the edge can re-sign mutated click params. +// In an opaque origin (sandboxed srcdoc without `allow-same-origin`) the JSON +// POST is cross-origin (`Origin: null`), triggers a CORS preflight the edge +// does not answer, and always fails — so the guard skips it and recovers via +// the GET navigation fallback, which the edge answers with a 302 chain (no +// CORS applies to navigations). async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Promise { const addKeys = Object.keys(diff.add); const delKeys = diff.del; @@ -154,7 +172,7 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom const fallback = buildProxyRebuildUrl(tsClickStr, diff); - if (typeof fetch !== 'function') { + if (typeof fetch !== 'function' || hasOpaqueOrigin()) { try { const el = a as Element; el.setAttribute('href', fallback); @@ -188,13 +206,7 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom const data = (await resp.json()) as { href?: string; base?: string } | null; const href = data && typeof data.href === 'string' ? data.href : null; if (href) { - const el = a as Element; - try { - el.setAttribute('data-tsclick', href); - el.setAttribute('href', href); - } catch (err) { - log.debug('tsjs-creative:click: failed to update anchor attributes', err); - } + persistRebuiltClick(a, href); log.info('tsjs-creative:click: rebuilt click', { added: addKeys, removed: delKeys, @@ -223,6 +235,12 @@ async function computeFinalUrl(a: AnchorLike, tsClickStr: string): Promise { const finalUrl = await rebuildIfNeeded(anchor, tsClickStr); if (finalUrl && finalUrl !== tsClickStr) { - try { - const el = anchor as Element; - el.setAttribute('data-tsclick', finalUrl); - el.setAttribute('href', finalUrl); - } catch (err) { - log.debug('tsjs-creative:click: failed to persist rebuilt href before navigation', err); - } + persistRebuiltClick(anchor, finalUrl); } navigate(anchor, finalUrl || tsClickStr, isMiddle); } @@ -318,16 +386,7 @@ function monitorAnchorMutations(): void { void rebuildIfNeeded(anchor, tsClickStr) .then((finalUrl) => { if (finalUrl && finalUrl !== tsClickStr) { - try { - const el = anchor as Element; - el.setAttribute('data-tsclick', finalUrl); - el.setAttribute('href', finalUrl); - } catch (err) { - log.debug( - 'tsjs-creative:click: failed to persist rebuilt href during mutation flush', - err - ); - } + persistRebuiltClick(anchor, finalUrl); } }) .catch((err) => { 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..5af2ea5e2 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,4 +1,5 @@ import { log } from '../../core/log'; +import { hasOpaqueOrigin } from '../../shared/origin'; const PROXY_PREFIX = '/first-party/proxy'; @@ -21,6 +22,15 @@ export function shouldProxyExternalUrl(raw: string): boolean { export async function signProxyUrl(raw: string): Promise { if (typeof fetch !== 'function') return null; + // A sandboxed srcdoc creative without `allow-same-origin` has an opaque + // origin: this JSON POST would preflight with `Origin: null` and fail, so + // skip the doomed request and leave the resource URL unsigned. Dynamic + // signing from opaque-origin creatives needs a same-origin parent + // postMessage broker — tracked in + // https://github.com/IABTechLab/trusted-server/issues/982. Until then, + // dynamically inserted resources degrade to loading directly, which the + // sandbox still isolates from the publisher origin. + if (hasOpaqueOrigin()) return null; let absolute: string; try { absolute = new URL(raw, location.href).toString(); diff --git a/crates/trusted-server-js/lib/src/shared/origin.ts b/crates/trusted-server-js/lib/src/shared/origin.ts new file mode 100644 index 000000000..801cafb4b --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/origin.ts @@ -0,0 +1,55 @@ +// Origin helpers shared by creative runtime modules. + +// A sandboxed srcdoc creative without `allow-same-origin` runs in an opaque +// origin: every fetch from it is cross-origin (`Origin: null`), and any +// preflighted request fails against endpoints that do not answer CORS. +// Callers use this to skip doomed requests and take a no-fetch path instead +// (navigation fallback, unsigned resource). Returns true when the origin +// cannot be determined — failing toward the no-fetch path is always safe. +export function hasOpaqueOrigin(): boolean { + try { + return typeof window !== 'undefined' && window.origin === 'null'; + } catch { + return true; + } +} + +// Base URL for resolving the root-relative first-party URLs the server-side +// rewriter emits. Pinned at module-load time, in descending order of trust: +// +// 1. `window.__tsCreativeOrigin` — stamped into the srcdoc document by the +// first-party parent page before any creative markup (see +// `core/render.ts`). This is the only source that is neither inherited nor +// ``-sensitive, so it wins wherever present. +// 2. `location.origin` — correct and ``-immune whenever the document has +// a real origin (creatives rendered outside the sandboxed srcdoc path). +// 3. `document.baseURI` — last resort inside an unstamped `srcdoc`, where +// `location.href` is `about:srcdoc` and `new URL` rejects it as a base. +// Inherited from the embedder and therefore honours a publisher ``. +// +// Pinning at load time means bidder script cannot redirect resolution later by +// injecting ``; the server-side rewriter also strips `` from +// creative markup whenever rewriting is enabled. +export const TRUSTED_BASE_URL: string = (() => { + try { + const stamped = (window as { __tsCreativeOrigin?: unknown }).__tsCreativeOrigin; + if (typeof stamped === 'string' && /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(stamped)) { + return stamped; + } + } catch { + // fall through + } + try { + const origin = location.origin; + if (origin && origin !== 'null') return origin; + } catch { + // fall through + } + try { + const base = typeof document !== 'undefined' ? document.baseURI : ''; + if (base && base !== 'about:srcdoc') return base; + } catch { + // fall through + } + return ''; +})(); diff --git a/crates/trusted-server-js/lib/test/core/render.test.ts b/crates/trusted-server-js/lib/test/core/render.test.ts index a81486cf3..5822f42b4 100644 --- a/crates/trusted-server-js/lib/test/core/render.test.ts +++ b/crates/trusted-server-js/lib/test/core/render.test.ts @@ -31,8 +31,12 @@ describe('render', () => { expect(sandbox).toContain('allow-popups'); expect(sandbox).toContain('allow-popups-to-escape-sandbox'); expect(sandbox).toContain('allow-top-navigation-by-user-activation'); - expect(sandbox).toContain('allow-same-origin'); expect(sandbox).toContain('allow-scripts'); + // `allow-scripts` + `allow-same-origin` together defeat the sandbox: creative + // markup would run with the publisher origin's privileges (cookies, storage, + // same-origin fetches). Matches APS_RENDERER_SANDBOX and ADM_IFRAME_SANDBOX, + // which already omit it. + expect(sandbox).not.toContain('allow-same-origin'); }); it('preserves dollar sequences when building the creative document', async () => { @@ -43,6 +47,21 @@ describe('render', () => { expect(documentHtml).toContain(creativeHtml); }); + it('stamps the first-party origin ahead of the creative markup', async () => { + // The srcdoc document has an opaque origin and an about:srcdoc location, so + // the creative runtime has no trustworthy origin of its own. This page — + // first-party and non-opaque — stamps the real one before any bidder markup + // can install a or otherwise influence resolution. + const { buildCreativeDocument } = await import('../../src/core/render'); + const creativeHtml = '
creative
'; + const documentHtml = buildCreativeDocument(creativeHtml); + + expect(documentHtml).toContain(`window.__tsCreativeOrigin = '${location.origin}'`); + expect(documentHtml.indexOf('__tsCreativeOrigin')).toBeLessThan( + documentHtml.indexOf(creativeHtml) + ); + }); + it('accepts safe static markup during sanitization', async () => { const { sanitizeCreativeHtml } = await import('../../src/core/render'); const sanitization = sanitizeCreativeHtml( 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 7cf31afa3..314123c16 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 @@ -4,6 +4,11 @@ import { FIRST_PARTY_CLICK, MUTATED_CLICK, PROXY_RESPONSE, importCreativeModule const ORIGINAL_FETCH = global.fetch; +// The guard persists validated, absolutized URLs (resolved against the pinned +// trusted base), so expectations compare against the absolute forms. +const absolute = (url: string): string => new URL(url, location.href).toString(); +const REBUILD_PREFIX = absolute('/first-party/proxy-rebuild?'); + describe('creative/click.ts', () => { beforeEach(() => { vi.resetModules(); @@ -32,7 +37,7 @@ describe('creative/click.ts', () => { await vi.runAllTimersAsync(); const finalHref = anchor.getAttribute('href') ?? ''; - expect(finalHref.startsWith('/first-party/proxy-rebuild?')).toBe(true); + expect(finalHref.startsWith(REBUILD_PREFIX)).toBe(true); expect(finalHref).toContain('add=%7B%22bar%22%3A%222%22%7D'); expect(finalHref).toContain('del=%5B%22foo%22%5D'); }); @@ -68,7 +73,170 @@ describe('creative/click.ts', () => { del: ['foo'], }); - expect(anchor.getAttribute('href')).toBe(PROXY_RESPONSE); - expect(anchor.getAttribute('data-tsclick')).toBe(PROXY_RESPONSE); + expect(anchor.getAttribute('href')).toBe(absolute(PROXY_RESPONSE)); + expect(anchor.getAttribute('data-tsclick')).toBe(absolute(PROXY_RESPONSE)); + }); + + it('skips the doomed POST and uses the GET fallback in an opaque origin', async () => { + // A sandboxed srcdoc creative without `allow-same-origin` has origin + // "null": its JSON POST preflights and fails, so the guard must go + // straight to the GET navigation fallback instead of fetching. + vi.useFakeTimers(); + + const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); + Object.defineProperty(window, 'origin', { value: 'null', configurable: true }); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ href: PROXY_RESPONSE }), + }); + 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).not.toHaveBeenCalled(); + const finalHref = anchor.getAttribute('href') ?? ''; + expect(finalHref.startsWith(REBUILD_PREFIX)).toBe(true); + expect(finalHref).toContain('add=%7B%22bar%22%3A%222%22%7D'); + expect(finalHref).toContain('del=%5B%22foo%22%5D'); + // The fallback must never become the canonical click: data-tsclick is + // what future mutation diffs compare against. + expect(anchor.getAttribute('data-tsclick')).toBe(FIRST_PARTY_CLICK); + } finally { + if (originDescriptor) { + Object.defineProperty(window, 'origin', originDescriptor); + } else { + delete (window as { origin?: string }).origin; + } + } + }); + + it('rebuilds a second mutation wave after an opaque-origin fallback', async () => { + // Wave 1 replaces href with the GET fallback; the canonical signed click in + // data-tsclick must survive so wave 2's mutation is still diffed and + // rebuilt instead of being lost. + vi.useFakeTimers(); + + const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); + Object.defineProperty(window, 'origin', { value: 'null', configurable: true }); + global.fetch = undefined 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(anchor.getAttribute('data-tsclick')).toBe(FIRST_PARTY_CLICK); + + anchor.setAttribute('href', 'https://example.com/landing?baz=3'); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + const finalHref = anchor.getAttribute('href') ?? ''; + expect(finalHref.startsWith(REBUILD_PREFIX)).toBe(true); + expect(finalHref).toContain('baz'); + expect(anchor.getAttribute('data-tsclick')).toBe(FIRST_PARTY_CLICK); + } finally { + if (originDescriptor) { + Object.defineProperty(window, 'origin', originDescriptor); + } else { + delete (window as { origin?: string }).origin; + } + } + }); + + it('navigates the observer-repaired fallback, not the pre-mutation click', async () => { + // Mutations made before any user interaction are repaired by the mutation + // observer, which writes the GET fallback to href while keeping the + // canonical signed click in data-tsclick. A later click must navigate the + // repaired URL — canonicalizing the fallback against the canonical click + // would fail the base comparison and silently navigate the original. + vi.useFakeTimers(); + + const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); + Object.defineProperty(window, 'origin', { value: 'null', configurable: true }); + global.fetch = undefined as unknown as typeof fetch; + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + + try { + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', FIRST_PARTY_CLICK); + // Force the middle-click branch so navigation lands in window.open, + // which jsdom can observe. + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + + await importCreativeModule(); + + // Wave 1: creative mutates the link, observer repairs it. + anchor.setAttribute('href', MUTATED_CLICK); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + const repaired = anchor.getAttribute('href') ?? ''; + expect(repaired.startsWith(REBUILD_PREFIX)).toBe(true); + + // Now the user clicks. + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(openMock).toHaveBeenCalled(); + const navigated = String(openMock.mock.calls[0][0]); + expect(navigated.startsWith(REBUILD_PREFIX)).toBe(true); + expect(navigated).toContain('add=%7B%22bar%22%3A%222%22%7D'); + expect(navigated).not.toBe(absolute(FIRST_PARTY_CLICK)); + } finally { + window.open = originalOpen; + if (originDescriptor) { + Object.defineProperty(window, 'origin', originDescriptor); + } else { + delete (window as { origin?: string }).origin; + } + } + }); + + it('refuses to navigate to or persist non-http(s) URLs', async () => { + // The guard reads creative-controlled attributes; a javascript: value must + // never reach location.href or an href write. + vi.useFakeTimers(); + global.fetch = undefined as unknown as typeof fetch; + + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', 'javascript:evil()'); + anchor.setAttribute('href', 'javascript:evil()'); + document.body.appendChild(anchor); + + await importCreativeModule(); + + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(anchor.getAttribute('href')).toBe('javascript:evil()'); + // jsdom throws on real navigation, so reaching this point without an + // unhandled navigation error is the assertion that location.href was + // never assigned the javascript: URL. }); }); 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 7f31dbed9..867c2b74e 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 @@ -51,4 +51,26 @@ describe('creative/proxy_sign.ts', () => { const result = await signProxyUrl('https://cdn.example/asset.js'); expect(result).toBeNull(); }); + + it('skips the doomed POST in an opaque origin and returns null', async () => { + // A sandboxed srcdoc creative without `allow-same-origin` has origin + // "null": the JSON POST would preflight and fail, so signing bails out + // without issuing the request. + const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); + Object.defineProperty(window, 'origin', { value: 'null', configurable: true }); + const fetchMock = vi.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + + try { + const result = await signProxyUrl('https://cdn.example/asset.js'); + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + if (originDescriptor) { + Object.defineProperty(window, 'origin', originDescriptor); + } else { + delete (window as { origin?: string }).origin; + } + } + }); }); diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 7111d80dd..e11ae8f07 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -12,7 +12,7 @@ Key capabilities: - **Strategy-based winner selection** — Automatic strategy detection based on configuration - **Mediator support** — Optional external mediator for decoding encoded prices (e.g., APS) and applying unified floor pricing - **Provider abstraction** — Pluggable provider interface for adding new demand sources -- **Creative rewriting** — Winning creatives are sanitized and rewritten with first-party proxy URLs by default +- **Creative processing** — Winning creatives are rewritten to first-party proxy URLs by default, with opt-in sanitization ## System Flow (Prebid + APS) @@ -147,7 +147,7 @@ sequenceDiagram Note over Client,Mock: Response Assembly activate TS activate Client - Orch->>Orch: Transform to OpenRTB response
Sanitize creative HTML
Optionally rewrite creative URLs
Add orchestrator metadata + Orch->>Orch: Transform to OpenRTB response
Optionally sanitize creative HTML
Optionally 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": "