Skip to content

Commit 1c98302

Browse files
committed
Run SSAT inline creative through the sanitize/rewrite boundary and decode cached bids
The inline-adm path fed raw bid.creative into window.tsjs.bids, bypassing the creative-processing boundary the /auction path applies (sanitize_creative_html then rewrite_creative_html, which also enforces the 1 MiB creative cap). Run the same boundary in build_bid_map before inserting adm; omit adm when the creative is rejected so the bridge falls back to the PBS Cache coordinates. Thread &Settings through build_bid_map and write_bids_to_state for rewrite_creative_html. The pbRender bridge's PBS Cache fallback forwarded the raw GET body as the ad, but PBS Cache returns a JSON bid object and the Prebid Universal Creative renders bidObject.adm. Add extractCachedAdm to parse the cached bid and extract its adm, keep raw-markup compatibility, and decline to render when no adm is present. Add hostile and oversized creative coverage in Rust, and realistic returnCreative=false, malformed, and raw-markup cache-response coverage in JS. Update the inject_adm_for_testing, AuctionBidData, GPT bridge, and design-spec docs to distinguish production inline adm from the testing-only debug_bid path.
1 parent 1159407 commit 1c98302

6 files changed

Lines changed: 371 additions & 56 deletions

File tree

crates/trusted-server-core/src/publisher.rs

Lines changed: 167 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
19381941
pub(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"

crates/trusted-server-core/src/settings.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,12 +1908,17 @@ pub struct DebugConfig {
19081908
#[serde(default)]
19091909
pub auction_html_comment: bool,
19101910

1911-
/// Include raw `adm` creative markup in `window.tsjs.bids` for GPT/GAM
1912-
/// debug rendering through the Prebid Universal Creative bridge.
1911+
/// Enable the testing-only direct GAM-replace path and the verbose per-bid
1912+
/// `debug_bid` blob in `window.tsjs.bids`.
19131913
///
1914-
/// Use this to validate the server-side auction→GAM targeting→creative
1915-
/// rendering pipeline while PBS Cache is unavailable. Never enable in
1916-
/// production — injects raw HTML from SSPs.
1914+
/// Note: the sanitized winning `adm` is now injected **unconditionally** for
1915+
/// production inline rendering through the pbRender bridge (see
1916+
/// [`crate::publisher::build_bid_map`]); this flag no longer gates `adm`.
1917+
/// What it still gates is the client-side `debug_bid` signal that turns on
1918+
/// the direct GAM-creative replacement (`injectAdmIntoSlot`), which bypasses
1919+
/// GAM entirely — useful for validating the auction→creative pipeline while
1920+
/// PBS Cache is unavailable. The `debug_bid` blob also carries the raw,
1921+
/// un-sanitized creative for diagnostics, so never enable in production.
19171922
#[serde(default)]
19181923
pub inject_adm_for_testing: bool,
19191924
}

crates/trusted-server-js/lib/src/core/types.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,21 @@ export interface AuctionBidData {
5757
hb_cache_path?: string;
5858
nurl?: string;
5959
burl?: string;
60-
/** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */
60+
/**
61+
* Sanitized winning creative markup for local rendering through the pbRender
62+
* bridge. Present whenever the winning bid carried a creative that passed the
63+
* server-side sanitize/rewrite boundary; absent when there was no creative or
64+
* it was rejected (e.g. over the 1 MiB cap), in which case the bridge falls
65+
* back to the PBS Cache coordinates. This is NOT gated by
66+
* `inject_adm_for_testing`.
67+
*/
6168
adm?: string;
62-
/** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */
69+
/**
70+
* Verbose per-bid debug blob (carries the raw, un-sanitized creative among
71+
* other fields). Only present when `[debug] inject_adm_for_testing = true`;
72+
* its presence is also the client-side gate for the testing-only direct
73+
* GAM-replace path.
74+
*/
6375
debug_bid?: AuctionDebugBidData;
6476
}
6577

0 commit comments

Comments
 (0)