From 6b4e82ed594d77fdc686107298823d83b2e3c19f Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Jul 2026 12:04:27 -0500 Subject: [PATCH 1/2] Make auction creative rewriting optional Allow operators to retain sanitizer-accepted external URLs in POST /auction adm while preserving mandatory server-side sanitization and the existing default behavior. --- CHANGELOG.md | 1 + .../src/auction/endpoints.rs | 7 +- .../src/auction/formats.rs | 141 +++++++++++++++++- .../src/auction/orchestrator.rs | 1 + .../src/auction_config_types.rs | 22 +++ .../trusted-server-core/src/config_payload.rs | 24 +++ crates/trusted-server-core/src/proxy.rs | 45 ++++++ crates/trusted-server-core/src/settings.rs | 35 +++++ docs/guide/auction-orchestration.md | 69 ++++++--- docs/guide/configuration.md | 26 +++- docs/guide/creative-processing.md | 54 +++++-- trusted-server.example.toml | 4 + 12 files changed, 382 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc49c80b..fddc8009d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,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 `/auction` creative sanitization while skipping first-party resource/click URL rewriting and creative TSJS injection. - 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-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 825129b1a..e7d4f0de4 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -76,9 +76,10 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// ## Response /// /// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's -/// `adm` field after sanitisation and first-party URL rewriting. Response -/// headers include `X-TS-EC` (the caller's Edge Cookie ID) and -/// `X-TS-EC-Fresh` (a freshly generated ID for cookie renewal). +/// `adm` field after mandatory server-side sanitization. First-party resource +/// and click URL rewriting plus creative TSJS injection are enabled by default; +/// setting [`auction.rewrite_creatives`][`crate::auction_config_types::AuctionConfig::rewrite_creatives`] +/// to `false` skips only that rewrite pass. /// /// ## Scroll, refresh, and SPA navigation /// diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 441828a18..71f9a290c 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -217,7 +217,8 @@ pub fn convert_tsjs_to_auction_request( /// Convert `OrchestrationResult` to `OpenRTB` response format. /// -/// Returns rewritten creative HTML directly in the `adm` field for inline delivery. +/// Always sanitizes creative HTML in the `adm` field and optionally rewrites it +/// according to the auction configuration. /// /// # Errors /// @@ -250,21 +251,34 @@ 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 - — sanitize dangerous markup first, then rewrite URLs. + // Process creative HTML if present — always sanitize dangerous markup first. let creative_html = if let Some(ref raw_creative) = bid.creative { let sanitized = creative::sanitize_creative_html(raw_creative); - let rewritten = creative::rewrite_creative_html(settings, &sanitized); + let sanitized_len = sanitized.len(); + let rewrite_creatives = settings.auction.rewrite_creatives; + let processed = if rewrite_creatives { + creative::rewrite_creative_html(settings, &sanitized) + } else { + sanitized + }; + let rewrite_mode = if rewrite_creatives { + "enabled" + } else { + "disabled" + }; log::debug!( - "Processed creative for auction {} slot {} ({} → {} → {} bytes)", + "Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", auction_request.id, slot_id, + bid.bidder, + rewrite_mode, raw_creative.len(), - sanitized.len(), - rewritten.len() + sanitized_len, + processed.len() ); - rewritten + processed } else { // No creative provided (e.g., from mediation layer that returns iframe URLs) log::warn!( @@ -445,6 +459,15 @@ mod tests { } } + fn make_complete_creative_bid() -> Bid { + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = Some( + r#""# + .to_string(), + ); + bid + } + fn make_result(bid: Bid) -> OrchestrationResult { OrchestrationResult { provider_responses: vec![AuctionResponse { @@ -466,6 +489,13 @@ mod tests { .expect("should parse JSON response") } + fn response_adm(response: Response) -> String { + response_json(response)["seatbid"][0]["bid"][0]["adm"] + .as_str() + .expect("should serialize adm as a string") + .to_string() + } + fn make_banner_body(config: Option) -> AdRequest { AdRequest { ad_units: vec![AdUnit { @@ -932,6 +962,103 @@ mod tests { ); } + #[test] + fn convert_to_openrtb_response_rewrites_sanitized_creative_by_default() { + let settings = make_settings(); + 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 enabled"); + let adm = response_adm(response); + + assert!( + adm.matches("/first-party/proxy?tsurl=").count() >= 2, + "should rewrite image and inline CSS URLs through the proxy: {adm}" + ); + assert!( + adm.contains("/first-party/click?tsurl="), + "should rewrite click URLs: {adm}" + ); + assert!( + adm.contains("data-tsclick"), + "should add the click guard attribute: {adm}" + ); + assert!( + adm.contains("tsjs-unified.min.js"), + "should inject the unified creative runtime: {adm}" + ); + assert!( + !adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should not retain the image URL as a direct attribute: {adm}" + ); + assert!( + !adm.contains(r#"href="https://advertiser.example.com/landing""#), + "should not retain the click URL as a direct attribute: {adm}" + ); + assert!( + !adm.contains("url(https://styles.example.com/bg.png)"), + "should not retain the CSS URL as a direct value: {adm}" + ); + assert!( + !adm.contains("auction-script-marker"), + "should remove malicious script content before rewriting: {adm}" + ); + assert!( + !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + "should remove event handlers before rewriting: {adm}" + ); + } + + #[test] + fn convert_to_openrtb_response_can_skip_rewriting_but_not_sanitization() { + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; + 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); + + assert!( + adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should retain the sanitizer-accepted image URL: {adm}" + ); + assert!( + adm.contains(r#"href="https://advertiser.example.com/landing""#), + "should retain the sanitizer-accepted click URL: {adm}" + ); + assert!( + adm.contains("url(https://styles.example.com/bg.png)"), + "should retain the sanitizer-accepted CSS URL: {adm}" + ); + assert!( + !adm.contains("/first-party/proxy"), + "should not rewrite resource URLs: {adm}" + ); + assert!( + !adm.contains("/first-party/click"), + "should not rewrite click URLs: {adm}" + ); + assert!( + !adm.contains("data-tsclick"), + "should not add the click guard attribute: {adm}" + ); + assert!( + !adm.contains("tsjs-unified.min.js"), + "should not inject the unified creative runtime: {adm}" + ); + assert!( + !adm.contains("auction-script-marker"), + "should still remove malicious script content: {adm}" + ); + assert!( + !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + "should still remove event handlers: {adm}" + ); + } + #[test] fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() { let settings = make_settings(); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 4ef73e581..fb9255825 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1818,6 +1818,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, + rewrite_creatives: true, providers: vec![], mediator: None, timeout_ms: 2000, diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 3bd747f64..f1d1a5cf0 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -11,6 +11,10 @@ pub struct AuctionConfig { #[serde(default)] pub enabled: bool, + /// Rewrite sanitized winning-bid creative HTML to first-party endpoints. + #[serde(default = "default_rewrite_creatives")] + pub rewrite_creatives: bool, + /// Provider names that participate in bidding /// Simply list the provider names (e.g., ["prebid", "aps"]) #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] @@ -41,6 +45,7 @@ impl Default for AuctionConfig { fn default() -> Self { Self { enabled: false, + rewrite_creatives: default_rewrite_creatives(), providers: Vec::new(), mediator: None, timeout_ms: default_timeout(), @@ -54,6 +59,10 @@ fn default_timeout() -> u32 { 2000 } +fn default_rewrite_creatives() -> bool { + true +} + fn default_creative_store() -> String { "creative_store".to_owned() } @@ -79,3 +88,16 @@ impl AuctionConfig { self.mediator.is_some() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_creatives_defaults_to_true() { + assert!( + AuctionConfig::default().rewrite_creatives, + "should enable creative rewriting by default" + ); + } +} diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index dd0b35337..58c185381 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -78,6 +78,30 @@ mod tests { ); } + #[test] + fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { + let mut data = + serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); + let auction = data + .get_mut("auction") + .and_then(serde_json::Value::as_object_mut) + .expect("should serialize auction settings as an object"); + assert!( + auction.remove("rewrite_creatives").is_some(), + "should remove the newly serialized setting" + ); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); + let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); + + let reconstructed = + settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); + + assert!( + reconstructed.auction.rewrite_creatives, + "should enable creative rewriting for legacy blobs" + ); + } + #[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/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 9e03f4dbd..9bc71fb22 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -2882,6 +2882,51 @@ mod tests { assert_eq!(ct, "text/css; charset=utf-8"); } + #[test] + fn auction_rewrite_setting_does_not_change_proxied_html_or_css_rewriting() { + let mut settings = create_test_settings(); + settings.auction.rewrite_creatives = false; + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + + let html = r#""#; + let mut html_response = build_http_response(StatusCode::OK, EdgeBody::from(html)); + html_response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + let html_output = finalize( + &settings, + &req, + "https://cdn.example/creative.html", + html_response, + ) + .expect("should finalize proxied HTML"); + let html_body = response_body_string(html_output); + + let css = "body{background:url(https://cdn.example/bg.png)}"; + let mut css_response = build_http_response(StatusCode::OK, EdgeBody::from(css)); + css_response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/css")); + let css_output = finalize( + &settings, + &req, + "https://cdn.example/creative.css", + css_response, + ) + .expect("should finalize proxied CSS"); + let css_body = response_body_string(css_output); + + assert!( + html_body.contains("/first-party/proxy?tsurl="), + "should keep rewriting proxied HTML when auction rewriting is disabled: {html_body}" + ); + assert!( + css_body.contains("/first-party/proxy?tsurl="), + "should keep rewriting proxied CSS when auction rewriting is disabled: {css_body}" + ); + } + #[test] fn html_response_rewrite_preserves_non_standard_port() { // Verify that HTML rewriting preserves non-standard ports in sub-resource URLs. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 03cc535c8..b177a23e7 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4334,6 +4334,41 @@ origin_host_header_overide = "www.example.com""#, assert!(!rewrite.is_excluded("")); } + #[test] + fn test_auction_rewrite_creatives_defaults_to_true_when_omitted() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + settings.auction.rewrite_creatives, + "should preserve creative rewriting when the setting is omitted" + ); + } + + #[test] + fn test_auction_rewrite_creatives_accepts_explicit_false() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + rewrite_creatives = false + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + !settings.auction.rewrite_creatives, + "should disable creative rewriting when explicitly configured" + ); + } + #[test] fn test_auction_allowed_context_keys_defaults_to_empty() { let settings = create_test_settings(); diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index d75958812..c6c82dac3 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 automatically rewritten with first-party proxy URLs +- **Creative rewriting** — Winning creatives are sanitized and rewritten with first-party proxy URLs by default ## 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
Generate iframe creatives
Rewrite creative URLs
Add orchestrator metadata + Orch->>Orch: Transform to OpenRTB response
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": "