diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 71f9a290c..b23331552 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -251,9 +251,15 @@ 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: when disabled + // the creative ships exactly as the bidder returned it. let creative_html = if let Some(ref raw_creative) = bid.creative { - let sanitized = creative::sanitize_creative_html(raw_creative); + let sanitize_creatives = settings.auction.sanitize_creatives; + let sanitized = if sanitize_creatives { + creative::sanitize_creative_html(raw_creative) + } else { + raw_creative.clone() + }; let sanitized_len = sanitized.len(); let rewrite_creatives = settings.auction.rewrite_creatives; let processed = if rewrite_creatives { @@ -261,6 +267,11 @@ pub fn convert_to_openrtb_response( } else { sanitized }; + let sanitize_mode = if sanitize_creatives { + "enabled" + } else { + "disabled" + }; let rewrite_mode = if rewrite_creatives { "enabled" } else { @@ -268,10 +279,11 @@ pub fn convert_to_openrtb_response( }; log::debug!( - "Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", + "Processed creative for auction {} slot {} bidder {} (sanitize {}, rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", auction_request.id, slot_id, bid.bidder, + sanitize_mode, rewrite_mode, raw_creative.len(), sanitized_len, @@ -963,8 +975,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()); @@ -1011,9 +1025,52 @@ 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 response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with sanitization disabled"); + let adm = response_adm(response); + + assert!( + adm.contains("auction-script-marker"), + "should retain script content when sanitization is disabled: {adm}" + ); + assert!( + adm.contains("auction-handler-marker"), + "should retain 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, + "creatives are delivered as the bidder returned them unless a publisher opts in" + ); + assert!( + !config.rewrite_creatives, + "creative URL rewriting is opt-in" + ); + } + + #[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()); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index b018518d5..68cc27289 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1823,6 +1823,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 eb93adbd1..f05c4df22 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -11,6 +11,22 @@ pub struct AuctionConfig { #[serde(default)] pub enabled: bool, + /// 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 sanitized winning-bid creative HTML to first-party endpoints. #[serde( default = "default_rewrite_creatives", @@ -48,6 +64,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, @@ -62,14 +79,22 @@ fn default_timeout() -> u32 { 2000 } +fn default_sanitize_creatives() -> bool { + false +} + fn default_rewrite_creatives() -> bool { - true + false } 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() } @@ -101,13 +126,17 @@ mod tests { use super::*; #[test] - fn rewrite_creatives_defaults_to_true() { + fn creative_processing_defaults_to_disabled() { let config: AuctionConfig = serde_json::from_value(serde_json::json!({})).expect("should deserialize defaults"); assert!( - config.rewrite_creatives, - "should enable creative rewriting by default" + !config.rewrite_creatives, + "creative rewriting is opt-in: creatives ship as the bidder returned them" + ); + assert!( + !config.sanitize_creatives, + "creative sanitization is opt-in: it strips executable markup with its content" ); } @@ -123,17 +152,44 @@ mod tests { } #[test] - fn disabled_rewrite_creatives_is_serialized() { + fn enabled_rewrite_creatives_is_serialized() { let config = AuctionConfig { - rewrite_creatives: false, + rewrite_creatives: true, ..AuctionConfig::default() }; - let serialized = serde_json::to_value(config).expect("should serialize disabled rewriting"); + let serialized = serde_json::to_value(config).expect("should serialize enabled rewriting"); assert_eq!( serialized.get("rewrite_creatives"), - Some(&serde_json::Value::Bool(false)), - "should preserve an explicit rewrite opt-out" + Some(&serde_json::Value::Bool(true)), + "should preserve an explicit rewrite opt-in" + ); + } + + #[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/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index f7ae531ea..bf32b0102 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -97,8 +97,8 @@ mod tests { } #[test] - fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { - let data = + fn legacy_blob_without_rewrite_creatives_leaves_rewriting_disabled() { + let mut data = serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); let auction = data .get("auction") @@ -115,8 +115,8 @@ mod tests { settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); assert!( - reconstructed.auction.rewrite_creatives, - "should enable creative rewriting for legacy blobs" + !reconstructed.auction.rewrite_creatives, + "creative rewriting is opt-in: a blob without the field leaves it disabled" ); } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index bdba093b7..ef5130d3d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4380,7 +4380,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_to_false_when_omitted() { let toml_str = crate_test_settings_str() + r#" [auction] @@ -4391,8 +4391,12 @@ origin_host_header_overide = "www.example.com""#, 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" + !settings.auction.rewrite_creatives, + "creative rewriting is opt-in 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-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts index ee08ef288..f00525b4e 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; 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..63a33c8a9 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 () => { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 99fabf3f2..d21a56ac7 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -112,11 +112,23 @@ rewrite_script = true [auction] enabled = false -# Defaults to true. Keep this leaf present when using the EdgeZero v0.0.4 -# environment override. Set false to return sanitized but unre-written winning-bid -# adm, skipping proxy/click URL conversion and creative TSJS injection. -# Sanitization is always applied. Restore and push true before an older-binary rollback. -rewrite_creatives = true +# Defaults to false. Keep this leaf present when using the EdgeZero v0.0.4 +# environment override. Set true to rewrite winning-bid adm to first-party +# endpoints, converting proxy/click URLs and injecting the creative TSJS runtime. +# Sanitization is controlled separately by `sanitize_creatives` below. +rewrite_creatives = false +# Strip executable markup (script/object/embed/form/...) from winning-bid adm, +# removing those elements together with their inner content. +# +# Defaults to false: creatives are delivered exactly as the bidder returned them. +# Enable whenever creatives can render in a context that shares the publisher's +# origin — it is the primary defence there. +# +# Leave disabled 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. Sanitization removes script-based creatives entirely, +# so enabling it on a script-heavy demand stack silently blanks those slots. +sanitize_creatives = false providers = [] timeout_ms = 2000 allowed_context_keys = []