@@ -697,6 +697,7 @@ pub async fn stream_publisher_body_async<W: Write>(
697697 & result. winning_bids ,
698698 params. price_granularity ,
699699 & params. ad_bids_state ,
700+ settings,
700701 settings. debug . inject_adm_for_testing ,
701702 ) ;
702703 return stream_publisher_body ( body, output, params, settings, integration_registry) ;
@@ -848,14 +849,15 @@ pub(crate) fn write_bids_to_state(
848849 winning_bids : & std:: collections:: HashMap < String , Bid > ,
849850 price_granularity : PriceGranularity ,
850851 ad_bids_state : & Arc < Mutex < Option < String > > > ,
852+ settings : & Settings ,
851853 include_debug_bid : bool ,
852854) {
853855 log:: debug!(
854856 "write_bids_to_state: {} winning bid(s): [{}]" ,
855857 winning_bids. len( ) ,
856858 winning_bids. keys( ) . cloned( ) . collect:: <Vec <_>>( ) . join( ", " )
857859 ) ;
858- let bid_map = build_bid_map ( winning_bids, price_granularity, include_debug_bid) ;
860+ let bid_map = build_bid_map ( winning_bids, price_granularity, settings , include_debug_bid) ;
859861 let bids_script = build_bids_script ( & bid_map) ;
860862 * ad_bids_state. lock ( ) . expect ( "should lock bid state" ) = Some ( bids_script) ;
861863}
@@ -1243,6 +1245,7 @@ async fn collect_stream_auction(
12431245 & result. winning_bids ,
12441246 price_granularity,
12451247 ad_bids_state,
1248+ settings,
12461249 settings. debug . inject_adm_for_testing ,
12471250 ) ;
12481251
@@ -1938,6 +1941,7 @@ fn html_escape_for_script(s: &str) -> String {
19381941pub ( crate ) fn build_bid_map (
19391942 winning_bids : & std:: collections:: HashMap < String , Bid > ,
19401943 granularity : crate :: price_bucket:: PriceGranularity ,
1944+ settings : & Settings ,
19411945 include_debug_bid : bool ,
19421946) -> serde_json:: Map < String , serde_json:: Value > {
19431947 winning_bids
@@ -1987,8 +1991,19 @@ pub(crate) fn build_bid_map(
19871991 // render it locally when GAM serves the Prebid Universal Creative
19881992 // — no PBS Cache round trip. The `hb_cache_*` coordinates above
19891993 // remain as the fallback for an absent `adm`.
1990- if let Some ( ref adm) = bid. creative {
1991- obj. insert ( "adm" . to_string ( ) , serde_json:: Value :: String ( adm. clone ( ) ) ) ;
1994+ //
1995+ // Run the same creative-processing boundary as the `/auction`
1996+ // path (see `auction::formats`): sanitize dangerous markup first,
1997+ // then rewrite URLs to first-party proxies. `sanitize_creative_html`
1998+ // also enforces the 1 MiB creative cap, returning an empty string
1999+ // for oversized or unparseable markup — in which case the entry is
2000+ // omitted and the bridge falls back to the PBS Cache coordinates.
2001+ if let Some ( ref raw_creative) = bid. creative {
2002+ let sanitized = crate :: creative:: sanitize_creative_html ( raw_creative) ;
2003+ let adm = crate :: creative:: rewrite_creative_html ( settings, & sanitized) ;
2004+ if !adm. is_empty ( ) {
2005+ obj. insert ( "adm" . to_string ( ) , serde_json:: Value :: String ( adm) ) ;
2006+ }
19922007 }
19932008 // Verbose per-bid debug blob only under the testing flag; also
19942009 // doubles as the client-side gate for the direct GAM-replace path.
@@ -2399,6 +2414,7 @@ pub async fn handle_page_bids(
23992414 let bid_map = build_bid_map (
24002415 & winning_bids,
24012416 co_config. price_granularity ,
2417+ settings,
24022418 settings. debug . inject_adm_for_testing ,
24032419 ) ;
24042420
@@ -3944,8 +3960,16 @@ mod tests {
39443960 } ;
39453961 use crate :: http_util:: RequestInfo ;
39463962 use crate :: price_bucket:: PriceGranularity ;
3963+ use crate :: settings:: Settings ;
39473964 use std:: collections:: HashMap ;
39483965
3966+ // Default settings are enough for the creative boundary: the sanitize
3967+ // pass needs no config, and `rewrite_creative_html` only signs URLs it
3968+ // actually rewrites (none of these fixtures carry proxyable URLs).
3969+ fn test_settings ( ) -> Settings {
3970+ Settings :: default ( )
3971+ }
3972+
39493973 fn make_config ( ) -> CreativeOpportunitiesConfig {
39503974 CreativeOpportunitiesConfig {
39513975 gam_network_id : "21765378893" . to_string ( ) ,
@@ -4049,7 +4073,12 @@ mod tests {
40494073 "https://ssp/bill" ,
40504074 ) ,
40514075 ) ;
4052- let map = build_bid_map ( & winning_bids, PriceGranularity :: Dense , false ) ;
4076+ let map = build_bid_map (
4077+ & winning_bids,
4078+ PriceGranularity :: Dense ,
4079+ & test_settings ( ) ,
4080+ false ,
4081+ ) ;
40534082 let entry = map. get ( "atf_sidebar_ad" ) . expect ( "should have bid entry" ) ;
40544083 let obj = entry. as_object ( ) . expect ( "should be object" ) ;
40554084 assert_eq ! (
@@ -4096,7 +4125,12 @@ mod tests {
40964125 // Production path (include_debug_bid = false): the creative is always
40974126 // included so the bridge can render it locally, but the verbose
40984127 // debug_bid blob is not.
4099- let map = build_bid_map ( & winning_bids, PriceGranularity :: Dense , false ) ;
4128+ let map = build_bid_map (
4129+ & winning_bids,
4130+ PriceGranularity :: Dense ,
4131+ & test_settings ( ) ,
4132+ false ,
4133+ ) ;
41004134 let obj = map
41014135 . get ( "atf_sidebar_ad" )
41024136 . expect ( "should have bid entry" )
@@ -4115,7 +4149,97 @@ mod tests {
41154149 }
41164150
41174151 #[ test]
4118- fn build_bids_script_escapes_hostile_adm ( ) {
4152+ fn build_bid_map_sanitizes_hostile_adm ( ) {
4153+ // The inline-adm path must run the same creative-processing boundary
4154+ // as the `/auction` path (sanitize → rewrite) before the creative
4155+ // reaches window.tsjs.bids, so hostile executable markup never lands
4156+ // in the client-facing `adm` for the Prebid Universal Creative to run.
4157+ let mut winning_bids = HashMap :: new ( ) ;
4158+ let mut bid = make_bid (
4159+ "atf_sidebar_ad" ,
4160+ 1.50 ,
4161+ "kargo" ,
4162+ "abc123" ,
4163+ "https://ssp/win" ,
4164+ "https://ssp/bill" ,
4165+ ) ;
4166+ bid. creative = Some (
4167+ "<div onclick=\" steal()\" ><script>alert(1)</script>\
4168+ <a href=\" javascript:evil()\" >x</a></div>"
4169+ . to_string ( ) ,
4170+ ) ;
4171+ winning_bids. insert ( "atf_sidebar_ad" . to_string ( ) , bid) ;
4172+
4173+ let map = build_bid_map (
4174+ & winning_bids,
4175+ PriceGranularity :: Dense ,
4176+ & test_settings ( ) ,
4177+ false ,
4178+ ) ;
4179+ let adm = map
4180+ . get ( "atf_sidebar_ad" )
4181+ . and_then ( |v| v. as_object ( ) )
4182+ . and_then ( |o| o. get ( "adm" ) )
4183+ . and_then ( |v| v. as_str ( ) )
4184+ . expect ( "should include a sanitized adm" ) ;
4185+
4186+ assert ! (
4187+ !adm. contains( "<script" ) ,
4188+ "should strip <script> elements from the inline adm"
4189+ ) ;
4190+ assert ! (
4191+ !adm. contains( "alert(1)" ) ,
4192+ "should strip inline script bodies from the inline adm"
4193+ ) ;
4194+ assert ! (
4195+ !adm. contains( "onclick" ) ,
4196+ "should strip on* event-handler attributes from the inline adm"
4197+ ) ;
4198+ assert ! (
4199+ !adm. contains( "javascript:" ) ,
4200+ "should strip javascript: URIs from the inline adm"
4201+ ) ;
4202+ }
4203+
4204+ #[ test]
4205+ fn build_bid_map_omits_oversized_adm ( ) {
4206+ // Creatives larger than the sanitize pass's 1 MiB cap are rejected
4207+ // (empty result), so the inline `adm` is omitted and the pbRender
4208+ // bridge falls back to the PBS Cache coordinates instead of shipping
4209+ // an unbounded creative to the client.
4210+ let mut winning_bids = HashMap :: new ( ) ;
4211+ let mut bid = make_bid (
4212+ "atf_sidebar_ad" ,
4213+ 1.50 ,
4214+ "kargo" ,
4215+ "abc123" ,
4216+ "https://ssp/win" ,
4217+ "https://ssp/bill" ,
4218+ ) ;
4219+ bid. creative = Some ( format ! ( "<div>{}</div>" , "a" . repeat( 1024 * 1024 + 1 ) ) ) ;
4220+ winning_bids. insert ( "atf_sidebar_ad" . to_string ( ) , bid) ;
4221+
4222+ let map = build_bid_map (
4223+ & winning_bids,
4224+ PriceGranularity :: Dense ,
4225+ & test_settings ( ) ,
4226+ false ,
4227+ ) ;
4228+ let obj = map
4229+ . get ( "atf_sidebar_ad" )
4230+ . and_then ( |v| v. as_object ( ) )
4231+ . expect ( "should have a bid entry" ) ;
4232+ assert ! (
4233+ obj. get( "adm" ) . is_none( ) ,
4234+ "should omit the inline adm when the creative exceeds the 1 MiB cap"
4235+ ) ;
4236+ }
4237+
4238+ #[ test]
4239+ fn build_bids_script_escapes_line_separators_in_adm ( ) {
4240+ // U+2028/U+2029 are valid JSON string content but terminate inline
4241+ // <script> statements; they survive the sanitize boundary as ordinary
4242+ // text, so build_bids_script must unicode-escape them.
41194243 let mut winning_bids = HashMap :: new ( ) ;
41204244 let mut bid = make_bid (
41214245 "s" ,
@@ -4125,17 +4249,16 @@ mod tests {
41254249 "https://ssp/win" ,
41264250 "https://ssp/bill" ,
41274251 ) ;
4128- // A hostile creative that tries to break out of the <script> and
4129- // includes both line/paragraph separators the spec promises to escape.
4130- bid. creative = Some ( "</script><script>alert(1)</script>\u{2028} \u{2029} " . to_string ( ) ) ;
4252+ bid. creative = Some ( "<div>a\u{2028} b\u{2029} c</div>" . to_string ( ) ) ;
41314253 winning_bids. insert ( "s" . to_string ( ) , bid) ;
41324254
4133- let map = build_bid_map ( & winning_bids , PriceGranularity :: Dense , false ) ;
4134- let script = build_bids_script ( & map ) ;
4135- assert ! (
4136- !script . contains ( "</script><script>" ) ,
4137- "should not let a hostile adm break out of the script context"
4255+ let map = build_bid_map (
4256+ & winning_bids ,
4257+ PriceGranularity :: Dense ,
4258+ & test_settings ( ) ,
4259+ false ,
41384260 ) ;
4261+ let script = build_bids_script ( & map) ;
41394262 assert ! (
41404263 !script. contains( '\u{2028}' ) && !script. contains( '\u{2029}' ) ,
41414264 "should unicode-escape both U+2028 and U+2029 in the adm"
@@ -4164,7 +4287,12 @@ mod tests {
41644287 ) ;
41654288 winning_bids. insert ( "atf_sidebar_ad" . to_string ( ) , bid) ;
41664289
4167- let map = build_bid_map ( & winning_bids, PriceGranularity :: Dense , true ) ;
4290+ let map = build_bid_map (
4291+ & winning_bids,
4292+ PriceGranularity :: Dense ,
4293+ & test_settings ( ) ,
4294+ true ,
4295+ ) ;
41684296 let obj = map
41694297 . get ( "atf_sidebar_ad" )
41704298 . expect ( "should have bid entry" )
@@ -4225,7 +4353,12 @@ mod tests {
42254353 metadata : Default :: default ( ) ,
42264354 } ,
42274355 ) ;
4228- let map = build_bid_map ( & winning_bids, PriceGranularity :: Dense , false ) ;
4356+ let map = build_bid_map (
4357+ & winning_bids,
4358+ PriceGranularity :: Dense ,
4359+ & test_settings ( ) ,
4360+ false ,
4361+ ) ;
42294362 let obj = map
42304363 . get ( "atf_sidebar_ad" )
42314364 . expect ( "should have bid entry" )
@@ -4271,7 +4404,12 @@ mod tests {
42714404 metadata : Default :: default ( ) ,
42724405 } ,
42734406 ) ;
4274- let map = build_bid_map ( & winning_bids, PriceGranularity :: Dense , false ) ;
4407+ let map = build_bid_map (
4408+ & winning_bids,
4409+ PriceGranularity :: Dense ,
4410+ & test_settings ( ) ,
4411+ false ,
4412+ ) ;
42754413 let obj = map
42764414 . get ( "atf_sidebar_ad" )
42774415 . expect ( "should have bid entry" )
@@ -4315,7 +4453,12 @@ mod tests {
43154453 metadata : Default :: default ( ) ,
43164454 } ,
43174455 ) ;
4318- let map = build_bid_map ( & winning_bids, PriceGranularity :: Dense , false ) ;
4456+ let map = build_bid_map (
4457+ & winning_bids,
4458+ PriceGranularity :: Dense ,
4459+ & test_settings ( ) ,
4460+ false ,
4461+ ) ;
43194462 let obj = map
43204463 . get ( "atf_sidebar_ad" )
43214464 . expect ( "should have bid entry" )
@@ -4350,7 +4493,12 @@ mod tests {
43504493 metadata : Default :: default ( ) ,
43514494 } ,
43524495 ) ;
4353- let map = build_bid_map ( & winning_bids, PriceGranularity :: Dense , false ) ;
4496+ let map = build_bid_map (
4497+ & winning_bids,
4498+ PriceGranularity :: Dense ,
4499+ & test_settings ( ) ,
4500+ false ,
4501+ ) ;
43544502 assert ! (
43554503 map. is_empty( ) ,
43564504 "slot with no price should be excluded from bid map"
0 commit comments