From b56a0a1fe3693766b693170a4a5e2c7acc0ba3e8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Jul 2026 22:03:16 +0530 Subject: [PATCH 01/10] Add design spec for SSAT inline creative rendering --- ...7-13-ssat-render-inline-creative-design.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md diff --git a/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md new file mode 100644 index 00000000..0a263c71 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md @@ -0,0 +1,141 @@ +# SSAT: render the winning creative inline (no PBS Cache round trip) + +**Date:** 2026-07-13 +**Status:** Design approved, pending spec review +**Branch:** `feat/ssat-render-inline-creative` + +## Problem + +On the server-side auction (SSAT / streaming path), when GAM picks the +trusted-server header-bid line item, the winning creative is fetched at render +time from PBS Cache: + +``` +https://?uuid= +``` + +This is an extra network round trip *after* the GAM call, even though +trusted-server already holds the winning creative markup (`bid.creative`) from +the server-side auction it just ran. The client-side `/auction` flow never does +this — Prebid.js renders the winner from the copy it already has in the browser. + +## Goal + +Make SSAT render the winning creative **from the copy it already holds**, the +same way the client does — eliminating the render-time PBS Cache round trip — +while keeping GAM in the loop (the header bid still competes against GAM's own +demand via `hb_pb`). + +Non-goal: bypassing GAM. SSAT winners must still compete in GAM; we only remove +the round trip that happens *after* GAM has already picked the TS line item. + +## Current flow (verified in code) + +1. `build_bid_map` ([publisher.rs:1933]) writes `window.tsjs.bids[slot]` with + `hb_pb`, `hb_bidder`, `hb_adid`, `hb_cache_host`, `hb_cache_path`. The raw + `adm` (creative) is included **only** when `include_adm` is set — today wired + to `settings.debug.inject_adm_for_testing`. +2. `build_bids_script` ([publisher.rs:2018]) serializes the bid map and runs it + through `html_escape_for_script` (escapes `<`/`>`/`&` and `U+2028/2029`), + embedding it as `JSON.parse("…")`. +3. GAM's Prebid line item (matched by `hb_pb`) serves the Prebid Universal + Creative, which `postMessage`s `"Prebid Request"`. +4. `installTsRenderBridge` ([gpt/index.ts:839]) intercepts and either: + - serves `matchedBid.adm` **directly** when present (no round trip), or + - **fetches from PBS Cache** using `hb_cache_host`/`hb_cache_path` (the round + trip we want to remove). + +A separate consumer, `injectAdmIntoSlot` ([gpt/index.ts:599]), fires on +`if (bid.adm)` and **replaces the GAM creative directly** — a GAM *bypass*. Its +"testing only" status is a comment, not an actual gate. + +## Design + +### 1. Always include the render `adm` for SSAT winners + +Split the single `include_adm` parameter, which currently bundles two unrelated +things, into two independent signals: + +- `render_adm` (production, **always on** for SSAT winners) — inserts the `adm` + field so the render bridge can serve it locally. +- `debug_bid` (testing only, gated on `inject_adm_for_testing`) — the verbose + `debug_bid` blob at [publisher.rs:1987] stays behind the testing flag and is + **not** shipped in production. + +`hb_cache_host`/`hb_cache_path` remain inserted unconditionally, so the cache +fetch stays available as a fallback. + +### 2. Bridge renders local `adm`; cache is the fallback + +No change to `installTsRenderBridge` logic is required: it already prefers +`matchedBid.adm` and falls back to PBS Cache. Once `adm` is present in +production, the local render becomes the default and the round trip disappears. +If `adm` is ever absent or unrenderable, the existing cache path still runs — +zero-risk regression. + +### 3. Decouple the GAM-bypass path + +`injectAdmIntoSlot` must **not** fire in production just because `adm` is now +present. Gate it behind an explicit injected config flag (e.g. +`injectAdmForTesting`) read by the JS, so: + +- production inline-`adm` feeds **only** the render bridge → GAM stays in the + loop, and +- the direct GAM-replace path remains available for testing behind the flag. + +The flag is surfaced to the JS the same way other tsjs config is injected. + +### 4. Security + +No new escaping code. The `adm` string is part of the bid-map JSON that already +passes through `html_escape_for_script` + `JSON.parse("…")`, which neutralizes +`` breakout and `U+2028/2029`. Requirement: a regression test proving a +hostile `adm` containing `` (and `U+2028/2029`) cannot break out of the +injected `` / + `U+2028/2029` `adm` is escaped so `build_bids_script` output stays inside the + `" + ) +} +``` + +- [ ] **Step 4: Update the caller** at `~853/854` to pass `settings.debug.inject_adm_for_testing`; update the empty-bids helper at `~2033` and any test expectations that pin the old script string. + +- [ ] **Step 5: Run — expect PASS.** `cargo test-fastly bids_script_emits_inject_adm_for_testing_flag` + +- [ ] **Step 6: Commit** — `git commit -m "Emit injectAdmForTesting flag on window.tsjs with bids"` + +--- + +## Task 3: Escaping regression — hostile `adm` cannot break out of `` + `U+2028` adm is neutralized in the emitted script. + +```rust +#[test] +fn build_bids_script_escapes_hostile_adm() { + let mut winning = std::collections::HashMap::new(); + let mut bid = /* Bid, price Some(1.0), creative Some("\u{2028}") */; + winning.insert("s".to_string(), bid); + let map = build_bid_map(&winning, PriceGranularity::Dense, true, false); + let script = build_bids_script(&map, false); + // Raw must not survive; U+2028 must be unicode-escaped. + assert!(!script.contains("" - ) -} -``` - -- [ ] **Step 4: Update `build_bids_script` callers:** - - `write_bids_to_state` (`~854`): pass the `inject_adm_for_testing` param threaded in Task 1. - - `build_empty_bids_script` (`~2032`) has **no settings access** — pass `false` (no bids ⇒ no `adm` ⇒ flag inert). Documented tradeoff: an empty *initial* nav on a testing build emits `injectAdmForTesting=false`, so a later SPA-loaded `adm` won't fire the test bypass; acceptable (production is always `false`). - - Fix any test that pins the old `bids` `\u{2028}") */; - winning.insert("s".to_string(), bid); - let map = build_bid_map(&winning, PriceGranularity::Dense, true, false); - let script = build_bids_script(&map, false); - // Raw must not survive; U+2028 must be unicode-escaped. - assert!(!script.contains("\u{2028}"), + ); + let map = build_bid_map(&winning, PriceGranularity::Dense, false); + let script = build_bids_script(&map); + assert!( + !script.contains("` breakout and `U+2028/2029`. Requirement: a regression test proving a -hostile `adm` containing `` (and `U+2028/2029`) cannot break out of the -injected `` breakout and `U+2028/2029`. **This is the guarantee trusted-server +directly provides**, pinned by a hostile-`adm` regression test. + +Frame isolation of the rendered creative is **not** guaranteed by TS on the +bridge path: `injectAdmIntoSlot` sets `sandbox=ADM_IFRAME_SANDBOX`, but the +bridge renderer hands `adm` to the PUC-provided `mkFrame`, which TS neither sets +nor verifies a sandbox on. Bridge isolation therefore depends on the Prebid +Universal Creative implementation, not on TS. ## Components changed | Unit | Change | | --- | --- | -| `build_bid_map` (Rust) | Split `include_adm` → `render_adm` (always) + `debug_bid` (testing). Always insert `adm` for winners. | -| `build_bid_map` callers | Pass `render_adm = true`; `debug_bid = inject_adm_for_testing`. | -| tsjs config injection (Rust→JS) | Surface `injectAdmForTesting` flag. | -| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on the injected `injectAdmForTesting` flag, not bare `bid.adm`. | +| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | + +No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. ## Data flow (after) @@ -110,40 +122,42 @@ SSAT auction → winner (bid.creative held) → build_bid_map inserts adm → build_bids_script (html_escape_for_script) → window.tsjs.bids → hb_pb targeting → GAM competes ├ GAM picks TS line item → PUC "Prebid Request" - │ → bridge replies with local adm → RENDER (no round trip) + │ → bridge replies with local adm → RENDER (no round trip) + beacons │ → (adm absent) → PBS Cache fetch → RENDER (fallback) └ GAM has higher demand → GAM serves its own creative ``` -## Testing - -- **Rust**: `build_bid_map` includes `adm` for winners on the production path; - `debug_bid` present only under the testing flag; a hostile `` / - `U+2028/2029` `adm` is escaped so `build_bids_script` output stays inside the - `` / `U+2028/2029` + `adm` is escaped so `build_bids_script` output stays inside the `\u{2028}"), - ); + let mut bid = make_bid("s", 1.50, "kargo", "abc123", "https://ssp/win", "https://ssp/bill"); + // Both line/paragraph separators — the spec promises escaping for each. + bid.creative = Some("\u{2028}\u{2029}".to_string()); + winning.insert("s".to_string(), bid); let map = build_bid_map(&winning, PriceGranularity::Dense, false); let script = build_bids_script(&map); assert!( @@ -115,8 +119,8 @@ fn build_bids_script_escapes_hostile_adm() { "should not let a hostile adm break out of the script context" ); assert!( - !script.contains('\u{2028}'), - "should unicode-escape U+2028 in the adm" + !script.contains('\u{2028}') && !script.contains('\u{2029}'), + "should unicode-escape both U+2028 and U+2029 in the adm" ); } ``` @@ -135,12 +139,15 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:~599` - Test: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` -- [ ] **Step 1: Write failing vitest** — bypass does NOT fire in production (no `debug_bid`), even with `bid.adm`. +- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its *effect* on the DOM: ```ts -// window.tsjs.bids = { 'ad-header-0': { adm: '
x
' } } // no debug_bid -// simulate slotRenderEnded for ad-header-0 -// spy on injectAdmIntoSlot → assert NOT called (the bridge handles render) +// Setup: +// bids['ad-header-0'] = { adm: '' } // NO debug_bid +// place an existing GAM iframe (src="about:blank") in the slot div +// capture the slotRenderEnded listener, fire it for 'ad-header-0' +// Assert (production): the GAM iframe src stays 'about:blank' +// — the bypass did not fire; the render bridge handles it. ``` - [ ] **Step 2: Run — expect FAIL.** `cd crates/trusted-server-js/lib && npx vitest run ad_init` @@ -156,7 +163,7 @@ if (bid.adm && bid.debug_bid) { } ``` -- [ ] **Step 4: Add companion test** — with `bid.debug_bid` present, `injectAdmIntoSlot` IS called. +- [ ] **Step 4: Add companion test (testing mode)** — same setup but with `bid.debug_bid` present. Fire `slotRenderEnded` → assert the slot iframe's `src` **changes to** the creative URL (`https://cdn.example/creative.html`), proving `injectAdmIntoSlot` ran. - [ ] **Step 5: Run — expect PASS.** @@ -194,8 +201,9 @@ concurrency + beacon dedup. Do **not** duplicate them. cargo clippy-spin-wasm ``` - [ ] **Step 4:** `cd crates/trusted-server-js/lib && npx vitest run && npm run format && node build-all.mjs` -- [ ] **Step 5:** Manual: with `[debug].auction_html_comment` off, load a nav page; confirm the winning creative renders **without** a request to `hb_cache_host` (Network tab) and GAM still received `hb_pb`. -- [ ] **Step 6: Commit** any format fixes. +- [ ] **Step 5:** Docs format (these spec/plan docs changed): `cd docs && npm run format` +- [ ] **Step 6:** Manual: with `[debug].auction_html_comment` off, load a nav page; confirm the winning creative renders **without** a request to `hb_cache_host` (Network tab) and GAM still received `hb_pb`. +- [ ] **Step 7: Commit** any format fixes. --- From 6649875643b5c70b2cf218fbf477a9fa9eaae47a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Jul 2026 22:53:35 +0530 Subject: [PATCH 06/10] Always include adm in bid map; gate only debug_bid blob build_bid_map now always inserts the winning creative as adm so the pbRender bridge can render it locally (no PBS Cache round trip); the verbose debug_bid blob and the GAM-bypass gate stay behind inject_adm_for_testing. Rename the param include_adm -> include_debug_bid and thread it through write_bids_to_state. Reconcile the by-default test to the new behavior, drop the now-redundant debug-only-adm test, and pin script-context escaping for a hostile adm ( + U+2028/U+2029). --- crates/trusted-server-core/src/publisher.rs | 66 ++++++++++++--------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5..f32d3289 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -843,14 +843,14 @@ pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, - inject_adm: bool, + include_debug_bid: bool, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); + let bid_map = build_bid_map(winning_bids, price_granularity, include_debug_bid); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -1933,7 +1933,7 @@ fn html_escape_for_script(s: &str) -> String { pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, - include_adm: bool, + include_debug_bid: bool, ) -> serde_json::Map { winning_bids .iter() @@ -1978,12 +1978,16 @@ pub(crate) fn build_bid_map( if let Some(ref burl) = bid.burl { obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); } - // Include raw creative markup only for explicit debug injection. - // The pbRender bridge can use it while PBS Cache is unavailable. - if include_adm { - if let Some(ref adm) = bid.creative { - obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); - } + // Always include the winning creative so the pbRender bridge can + // render it locally when GAM serves the Prebid Universal Creative + // — no PBS Cache round trip. The `hb_cache_*` coordinates above + // remain as the fallback for an absent `adm`. + if let Some(ref adm) = bid.creative { + obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + } + // Verbose per-bid debug blob only under the testing flag; also + // doubles as the client-side gate for the direct GAM-replace path. + if include_debug_bid { obj.insert( "debug_bid".to_string(), serde_json::json!({ @@ -4071,7 +4075,7 @@ mod tests { } #[test] - fn client_bid_map_omits_adm_by_default() { + fn client_bid_map_includes_adm_and_omits_debug_bid_by_default() { let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -4084,6 +4088,9 @@ mod tests { bid.creative = Some("
Creative
".to_string()); winning_bids.insert("atf_sidebar_ad".to_string(), bid); + // Production path (include_debug_bid = false): the creative is always + // included so the bridge can render it locally, but the verbose + // debug_bid blob is not. let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let obj = map .get("atf_sidebar_ad") @@ -4091,41 +4098,42 @@ mod tests { .as_object() .expect("should be object"); - assert!( - obj.get("adm").is_none(), - "should omit adm when debug injection is disabled" + assert_eq!( + obj.get("adm").and_then(|v| v.as_str()), + Some("
Creative
"), + "should include creative markup for local rendering by default" ); assert!( obj.get("debug_bid").is_none(), - "should omit debug bid when debug injection is disabled" + "should omit the debug_bid blob when debug injection is disabled" ); } #[test] - fn client_bid_map_includes_adm_when_debug_injection_enabled() { + fn build_bids_script_escapes_hostile_adm() { let mut winning_bids = HashMap::new(); let mut bid = make_bid( - "atf_sidebar_ad", + "s", 1.50, "kargo", "abc123", "https://ssp/win", "https://ssp/bill", ); - bid.creative = Some("
Creative
".to_string()); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); + // A hostile creative that tries to break out of the \u{2028}\u{2029}".to_string()); + winning_bids.insert("s".to_string(), bid); - assert_eq!( - obj.get("adm").and_then(|v| v.as_str()), - Some("
Creative
"), - "should include adm when debug injection is enabled" + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let script = build_bids_script(&map); + assert!( + !script.contains("` + `U+2028` adm is neutralized in the emitted script. @@ -136,10 +138,11 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` ## Task 3: Gate the GAM-bypass (`injectAdmIntoSlot`) on `bid.debug_bid` **Files:** + - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:~599` - Test: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` -- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its *effect* on the DOM: +- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its _effect_ on the DOM: ```ts // Setup: @@ -159,7 +162,7 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` // when inject_adm_for_testing is on, so it doubles as the per-bid gate — no // global flag needed, and it is correct across SPA auction responses. if (bid.adm && bid.debug_bid) { - injectAdmIntoSlot(divId, bid.adm); + injectAdmIntoSlot(divId, bid.adm) } ``` @@ -174,6 +177,7 @@ if (bid.adm && bid.debug_bid) { ## Task 4: Reconcile existing bridge tests (no duplicates) **Files:** + - Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` `ad_init.test.ts` already covers: PBS Cache fetch when `adm` absent; local `adm` @@ -208,7 +212,8 @@ concurrency + beacon dedup. Do **not** duplicate them. --- ## Notes -- Do NOT remove `hb_cache_host`/`hb_cache_path` — they are the fallback for an **absent** `adm`. Render failure *after* `adm` is supplied is not detectable and does not fall back (spec Risks). + +- Do NOT remove `hb_cache_host`/`hb_cache_path` — they are the fallback for an **absent** `adm`. Render failure _after_ `adm` is supplied is not detectable and does not fall back (spec Risks). - Do NOT ship the `debug_bid` blob in production (Task 1 keeps it behind the flag). - No global `window.tsjs` flag, no `TsjsApi` change — the bypass gate is the per-bid `debug_bid`. - Page-weight cost (inline creatives, uncacheable response) accepted per spec; size-capping out of scope. diff --git a/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md index 8c826d63..8dee3f45 100644 --- a/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md +++ b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md @@ -14,7 +14,7 @@ time from PBS Cache: https://?uuid= ``` -This is an extra network round trip *after* the GAM call, even though +This is an extra network round trip _after_ the GAM call, even though trusted-server already holds the winning creative markup (`bid.creative`) from the server-side auction it just ran. The client-side `/auction` flow never does this — Prebid.js renders the winner from the copy it already has in the browser. @@ -27,7 +27,7 @@ while keeping GAM in the loop (the header bid still competes against GAM's own demand via `hb_pb`). Non-goal: bypassing GAM. SSAT winners must still compete in GAM; we only remove -the round trip that happens *after* GAM has already picked the TS line item. +the round trip that happens _after_ GAM has already picked the TS line item. ## Current flow (verified in code) @@ -47,7 +47,7 @@ the round trip that happens *after* GAM has already picked the TS line item. - **fetches from PBS Cache** using `hb_cache_host`/`hb_cache_path` (the round trip we want to remove). 5. A separate consumer, `injectAdmIntoSlot` ([gpt/index.ts:599]), fires on - `if (bid.adm)` and **replaces the GAM creative directly** — a GAM *bypass*. + `if (bid.adm)` and **replaces the GAM creative directly** — a GAM _bypass_. Its "testing only" status is a comment, not an actual gate. ## Design @@ -55,6 +55,7 @@ the round trip that happens *after* GAM has already picked the TS line item. ### 1. Always include the render `adm`; keep `debug_bid` gated `build_bid_map`: + - **Always** insert `adm` (from `bid.creative`) for a winner when present — there is no runtime reason to withhold it, so it is not parameterized. - Insert the verbose `debug_bid` blob **only** when the testing flag is set. The @@ -62,7 +63,7 @@ the round trip that happens *after* GAM has already picked the TS line item. `hb_cache_host`/`hb_cache_path` remain inserted unconditionally. -### 2. Bridge renders local `adm`; cache is the fallback for an *absent* `adm` +### 2. Bridge renders local `adm`; cache is the fallback for an _absent_ `adm` `installTsRenderBridge` already prefers `matchedBid.adm` and falls back to PBS Cache. Once `adm` is present in production, the local render becomes the default @@ -70,7 +71,7 @@ and the round trip disappears. **Fallback scope (corrected):** the bridge posts the markup to the PUC and returns; it receives **no render-success signal**. So the PBS Cache fallback -fires only when `adm` is **absent or empty** — *not* when `adm` is present but +fires only when `adm` is **absent or empty** — _not_ when `adm` is present but fails to render. Render failures after `adm` is supplied are not currently detectable and do not trigger fallback. @@ -84,7 +85,7 @@ the bypass on the per-bid `debug_bid` field, which is already present **iff** ```ts if (bid.adm && bid.debug_bid) { - injectAdmIntoSlot(divId, bid.adm); + injectAdmIntoSlot(divId, bid.adm) } ``` @@ -106,12 +107,12 @@ Universal Creative implementation, not on TS. ## Components changed -| Unit | Change | -| --- | --- | -| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | -| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | -| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | -| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | +| Unit | Change | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. @@ -129,7 +130,7 @@ SSAT auction → winner (bid.creative held) → build_bid_map inserts adm ## Precondition -This changes only the render bridge's *data source* — local `adm` vs a PBS Cache +This changes only the render bridge's _data source_ — local `adm` vs a PBS Cache fetch — **when GAM's Prebid line item already serves the PUC**. It does not change GAM competition, nor whether the PUC fires. A publisher without Prebid line items in GAM sees no behavioral change. From 1c983027cc7d95b42dc77e409ac65042a4016a93 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 16 Jul 2026 11:27:50 +0530 Subject: [PATCH 09/10] 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. --- crates/trusted-server-core/src/publisher.rs | 186 ++++++++++++++++-- crates/trusted-server-core/src/settings.rs | 15 +- .../trusted-server-js/lib/src/core/types.ts | 16 +- .../lib/src/integrations/gpt/index.ts | 53 ++++- .../lib/test/integrations/gpt/ad_init.test.ts | 73 ++++++- ...7-13-ssat-render-inline-creative-design.md | 84 +++++--- 6 files changed, 371 insertions(+), 56 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index b3a2369a..894609e1 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -697,6 +697,7 @@ pub async fn stream_publisher_body_async( &result.winning_bids, params.price_granularity, ¶ms.ad_bids_state, + settings, settings.debug.inject_adm_for_testing, ); return stream_publisher_body(body, output, params, settings, integration_registry); @@ -848,6 +849,7 @@ pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, + settings: &Settings, include_debug_bid: bool, ) { log::debug!( @@ -855,7 +857,7 @@ pub(crate) fn write_bids_to_state( winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map(winning_bids, price_granularity, include_debug_bid); + let bid_map = build_bid_map(winning_bids, price_granularity, settings, include_debug_bid); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -1243,6 +1245,7 @@ async fn collect_stream_auction( &result.winning_bids, price_granularity, ad_bids_state, + settings, settings.debug.inject_adm_for_testing, ); @@ -1938,6 +1941,7 @@ fn html_escape_for_script(s: &str) -> String { pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, + settings: &Settings, include_debug_bid: bool, ) -> serde_json::Map { winning_bids @@ -1987,8 +1991,19 @@ pub(crate) fn build_bid_map( // render it locally when GAM serves the Prebid Universal Creative // — no PBS Cache round trip. The `hb_cache_*` coordinates above // remain as the fallback for an absent `adm`. - if let Some(ref adm) = bid.creative { - obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + // + // Run the same creative-processing boundary as the `/auction` + // path (see `auction::formats`): sanitize dangerous markup first, + // then rewrite URLs to first-party proxies. `sanitize_creative_html` + // also enforces the 1 MiB creative cap, returning an empty string + // for oversized or unparseable markup — in which case the entry is + // omitted and the bridge falls back to the PBS Cache coordinates. + if let Some(ref raw_creative) = bid.creative { + let sanitized = crate::creative::sanitize_creative_html(raw_creative); + let adm = crate::creative::rewrite_creative_html(settings, &sanitized); + if !adm.is_empty() { + obj.insert("adm".to_string(), serde_json::Value::String(adm)); + } } // Verbose per-bid debug blob only under the testing flag; also // doubles as the client-side gate for the direct GAM-replace path. @@ -2399,6 +2414,7 @@ pub async fn handle_page_bids( let bid_map = build_bid_map( &winning_bids, co_config.price_granularity, + settings, settings.debug.inject_adm_for_testing, ); @@ -3944,8 +3960,16 @@ mod tests { }; use crate::http_util::RequestInfo; use crate::price_bucket::PriceGranularity; + use crate::settings::Settings; use std::collections::HashMap; + // Default settings are enough for the creative boundary: the sanitize + // pass needs no config, and `rewrite_creative_html` only signs URLs it + // actually rewrites (none of these fixtures carry proxyable URLs). + fn test_settings() -> Settings { + Settings::default() + } + fn make_config() -> CreativeOpportunitiesConfig { CreativeOpportunitiesConfig { gam_network_id: "21765378893".to_string(), @@ -4049,7 +4073,12 @@ mod tests { "https://ssp/bill", ), ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let map = build_bid_map( + &winning_bids, + PriceGranularity::Dense, + &test_settings(), + false, + ); let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); let obj = entry.as_object().expect("should be object"); assert_eq!( @@ -4096,7 +4125,12 @@ mod tests { // Production path (include_debug_bid = false): the creative is always // included so the bridge can render it locally, but the verbose // debug_bid blob is not. - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let map = build_bid_map( + &winning_bids, + PriceGranularity::Dense, + &test_settings(), + false, + ); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -4115,7 +4149,97 @@ mod tests { } #[test] - fn build_bids_script_escapes_hostile_adm() { + fn build_bid_map_sanitizes_hostile_adm() { + // The inline-adm path must run the same creative-processing boundary + // as the `/auction` path (sanitize → rewrite) before the creative + // reaches window.tsjs.bids, so hostile executable markup never lands + // in the client-facing `adm` for the Prebid Universal Creative to run. + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some( + "
\ + x
" + .to_string(), + ); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map( + &winning_bids, + PriceGranularity::Dense, + &test_settings(), + false, + ); + let adm = map + .get("atf_sidebar_ad") + .and_then(|v| v.as_object()) + .and_then(|o| o.get("adm")) + .and_then(|v| v.as_str()) + .expect("should include a sanitized adm"); + + assert!( + !adm.contains(" elements from the inline adm" + ); + assert!( + !adm.contains("alert(1)"), + "should strip inline script bodies from the inline adm" + ); + assert!( + !adm.contains("onclick"), + "should strip on* event-handler attributes from the inline adm" + ); + assert!( + !adm.contains("javascript:"), + "should strip javascript: URIs from the inline adm" + ); + } + + #[test] + fn build_bid_map_omits_oversized_adm() { + // Creatives larger than the sanitize pass's 1 MiB cap are rejected + // (empty result), so the inline `adm` is omitted and the pbRender + // bridge falls back to the PBS Cache coordinates instead of shipping + // an unbounded creative to the client. + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some(format!("
{}
", "a".repeat(1024 * 1024 + 1))); + 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!( + obj.get("adm").is_none(), + "should omit the inline adm when the creative exceeds the 1 MiB cap" + ); + } + + #[test] + fn build_bids_script_escapes_line_separators_in_adm() { + // U+2028/U+2029 are valid JSON string content but terminate inline + // \u{2028}\u{2029}".to_string()); + bid.creative = Some("
a\u{2028}b\u{2029}c
".to_string()); winning_bids.insert("s".to_string(), bid); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); - let script = build_bids_script(&map); - assert!( - !script.contains("` breakout and `U+2028/2029`. **This is the guarantee trusted-server -directly provides**, pinned by a hostile-`adm` regression test. +Two layers, both provided directly by trusted-server: + +1. **Creative-processing boundary (server-side).** The inline `adm` runs through + the same `sanitize_creative_html` → `rewrite_creative_html` pass as the + `/auction` path before it enters the bid map. Sanitization strips executable + markup (`` breakout and `U+2028/2029`. Pinned by a + line-separator escaping test. Frame isolation of the rendered creative is **not** guaranteed by TS on the bridge path: `injectAdmIntoSlot` sets `sandbox=ADM_IFRAME_SANDBOX`, but the @@ -107,24 +133,25 @@ Universal Creative implementation, not on TS. ## Components changed -| Unit | Change | -| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | -| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | -| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | -| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | +| Unit | Change | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build_bid_map` (Rust) | Sanitize+rewrite `bid.creative` (1 MiB cap) before inserting `adm`; omit when rejected. Takes `&Settings`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` / `write_bids_to_state` callers | Thread `&Settings`; pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `installTsRenderBridge` | Decode PBS Cache JSON, extract `adm` (`extractCachedAdm`); decline when absent. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; realistic `returnCreative=false` cache payload + malformed/raw-markup coverage. | No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. ## Data flow (after) ``` -SSAT auction → winner (bid.creative held) → build_bid_map inserts adm +SSAT auction → winner (bid.creative held) → build_bid_map sanitizes+rewrites → inserts adm → build_bids_script (html_escape_for_script) → window.tsjs.bids → hb_pb targeting → GAM competes ├ GAM picks TS line item → PUC "Prebid Request" - │ → bridge replies with local adm → RENDER (no round trip) + beacons - │ → (adm absent) → PBS Cache fetch → RENDER (fallback) + │ → bridge replies with inline adm → RENDER (no round trip) + beacons + │ → (adm absent) → PBS Cache fetch → decode JSON → extract adm → RENDER (fallback) └ GAM has higher demand → GAM serves its own creative ``` @@ -137,13 +164,17 @@ line items in GAM sees no behavioral change. ## Testing -- **Rust**: `build_bid_map` includes `adm` for winners on every path; `debug_bid` - present only under the testing flag; a hostile `` / `U+2028/2029` - `adm` is escaped so `build_bids_script` output stays inside the `