Add first-class APS OpenRTB integration#918
Conversation
aram356
left a comment
There was a problem hiding this comment.
Summary
Replaces the legacy APS TAM/contextual path with a first-class OpenRTB provider, adds a sandboxed opaque-origin creative renderer, a Prebid-adapter rendering route, PBS coexistence safeguards, and an inventory-identity override. The security core is well-hardened and I verified rather than assumed it — allow-same-origin is never combined with allow-scripts (pinned by a regression test), the nonce/envelope contract matches byte-for-byte across Rust and JS, and the two CodeQL alerts were genuinely resolved. CI is green.
That said, I found 7 blocking defects — an auction-wide failure from one malformed bid, two ways APS demand can still reach Prebid Server, two blank-slot / consumed-impression bugs in the new rendering routes, a privacy leak in the identity override, and a silent config break on upgrade. Requesting changes.
A process note: the PR body's change table documents only the first commit. Two later commits ("Add APS inventory identity overrides", "Render APS bids from the trustedServer Prebid adapter") added whole subsystems it omits — worth refreshing before merge.
Most findings are filed as inline comments. The cross-cutting ones are below.
Blocking
🔧 wrench
- APS exclusion is case-sensitive and leaks via the head-insert path (prebid.rs:1427/1434, prebid.rs:909): Both PBS guards compare
name == "aps"exactly, so"APS"/"Aps"in the free-formconfig.biddersslips through to PBS and is sold twice. Separately,head_insertsserializesconfig.bidders/client_side_biddersverbatim intowindow.__tsjs_prebid, soapsstill ships to the browser — the safeguard is server-side only, andclient_side_biddersis never filtered at all. The durable fix for this and the stored-request finding is to rejectapsin[prebid].bidders/client_side_biddersat config-validation time.
Non-blocking
🤔 thinking
- Full client-controlled Referer path+query now enters the bidstream (formats.rs:93-116):
site.pagechanged from a barehttps://{domain}to the full same-origin Referer including query string, so publisher URLs carrying PII (?email=, session tokens,gclid) are forwarded to every SSP — up to 8 KiB of attacker-chosen data (the host check constrains only the authority). This mirrors client-side Prebid so it may be intentional, but for a privacy-preserving edge server it deserves an explicit decision: strip query/fragment by default, or gate behind config. adserver_mockmediation is last-write-wins and the new test enshrines it (adserver_mock.rs:107, :899):build_bid_indexkeys on(provider, slot, bidder); if APS ever stops reducing to one bid per slot, the mediator restores the losing candidate's renderer against the winning bid's price — wrong creative at wrong price, silently.bid_idnow exists and would disambiguate; the mock only echoescrid. At least raise the collision log fromdebug!towarn!.providers.apsis now write-only and fails silently (creative_opportunities.rs:366,378): the field is a genuinedeny_unknown_fieldsdeser-compat shim (removing it hard-fails existing TOML), butto_ad_slotno longer reads it, so an operator who setsaps.slot_idexpecting routing gets a no-op with zero diagnostics. Add a load-time warning.
♻️ refactor
BidRenderer::aps()is an infallible accessor on a single-variant enum (types.rs:217-225, used at publisher.rs:1957): the moment a second variant lands the natural "fix" is a panic in the fallback arm. Preferas_aps(&self) -> Option<&ApsRendererV1>. Note the genericbid.bid_idfield this PR adds already carries the same value, so publisher.rs could derivehb_adidrenderer-agnostically.
📝 note
- Undocumented
/auctionwire-contract change: for renderer bids,cridgoes from always-present{bidder}-creativetobid.creative_id(absent when the bidder omitscrid),idfrom{bidder}-{slot}to the upstream bid id, andadmis omitted entirely. tsjs absorbs all three, butPOST /auctionis a documented OpenRTB endpoint; any non-tsjs consumer reading those fields breaks. The CHANGELOG's Breaking section doesn't mention the response shape. - Test quality across the new suites: the browser same-origin rejection test (aps-renderer.spec.ts:731-805) is effectively vacuous — a fixed
waitForTimeout(100)with no positive control, driven through a hand-rolled harness instead ofrenderApsCreative, so it would pass if the guard broke. Several sandbox assertions (:464,:555) assert the harness's own input rather than product behavior. And there is no JS-side boundary coverage for the size caps (envelope 256 KiB / base64 349528, account-id 1024, creative-url 4096) nor a regression test for the two blocking rendering-route bugs. (I did confirm the same-origin guard at aps.rs:74 is live, not dead code, via a browser test — so that check is real; the test just doesn't exercise it.)
⛏ nitpick
creative_idis the only renderer field without a length cap (aps.rs:682) — bounded by the 2 MiB body cap but inconsistent withaccount_id/creative_url.dropped_bid_countanddrop_reasonsdon't reconcile (aps.rs:776-799) — price-losers increment the count with no reason entry; the test assertsdropped=1, drop_reasons={}.- auction/README.md:120 ASCII box right border is off by one column.
trusted-server.example.tomlregressed a fictionalaps.example.com/e/dtb/bidto a real vendor host; several docs/tests use real*.amazon-adsystem.comendpoints against the project's "example.com only" rule. These are functionally-required vendor endpoints (not customer data or credentials) andamazon-adsystempredates this PR, so noting rather than blocking.
CI Status
- fmt: PASS
- clippy (all six adapter targets): PASS
- rust tests (fastly/axum/cloudflare/spin + parity): PASS
- js tests (vitest): PASS
- browser integration + CodeQL: PASS
| String::new() | ||
| return Err(Report::new(TrustedServerError::Auction { | ||
| message: format!( | ||
| "Winning bid for slot '{}' from '{}' has no render source", |
There was a problem hiding this comment.
🔧 wrench — A single render-source-less winning bid fails the entire /auction response.
This else arm returns Err, which aborts the whole loop over result.winning_bids; endpoints.rs propagates it straight out of POST /auction, so every slot loses its demand, not just this bid.
The path is reachable today: parse_bid in prebid.rs:1874 sets creative from adm with no rejection when absent and hardcodes renderer: None (prebid.rs:1989). A legal nurl-only OpenRTB bid (adm omitted, fetched via nurl) yields price: Some, creative: None, renderer: None, and neither select_winning_bids nor apply_floor_prices filters on render source. One malformed low-value bid from any upstream blanks the whole page.
Fail-closed on the bid is right; fail-closed on the response is not. Skip the bid instead, and treat empty adm as absent so creative: Some("") + renderer: Some(_) doesn't ship a blank ad either:
let creative = bid.creative.as_deref().filter(|c| !c.trim().is_empty());
let (adm, ext) = if let Some(raw_creative) = creative {
// ...sanitize + rewrite...
(Some(rewritten), None)
} else if let Some(renderer) = bid.renderer.as_ref() {
(None, BidExt { trusted_server: BidTrustedServerExt { renderer } }.to_ext())
} else {
log::warn!("Dropping winning bid for slot '{}' from '{}' - no render source", slot_id, bid.bidder);
continue;
};| BidExt { | ||
| trusted_server: BidTrustedServerExt { renderer }, | ||
| } | ||
| .to_ext(), |
There was a problem hiding this comment.
🤔 thinking — to_ext() can silently erase the renderer.
ToExt::to_ext swallows serialization failure and empty objects into None. The code fails-closed on a missing render source just above, then lets the renderer vanish here into a bid with neither adm nor ext → blank ad, no log. Unreachable for ApsRendererV1 (all String/u32), but the invariant is unguarded — consider .ok_or_else(|| Report::new(TrustedServerError::Auction { ... }))?.
| .headers() | ||
| .get(header::REFERER) | ||
| .and_then(|value| value.to_str().ok()) | ||
| .filter(|value| value.len() <= MAX_PUBLISHER_PAGE_URL_BYTES) |
There was a problem hiding this comment.
🤔 thinking — Full client-controlled Referer path+query now enters the bidstream.
site.page changed from a bare https://{domain} to the full same-origin Referer including query string. /auction is same-origin, so browsers send path+query under the default strict-origin-when-cross-origin. Publisher URLs routinely carry PII (?email=, session tokens, gclid), and this now forwards to every SSP — up to 8 KiB of attacker-chosen data (the host filter constrains only the authority). This matches client-side Prebid so it may be intentional, but for a privacy-preserving edge server it deserves an explicit decision: strip query/fragment by default, or gate behind config. Also: exact host equality silently rejects www.example.com for a publisher configured as example.com, with no diagnostic log.
| } | ||
| if name == TRUSTED_SERVER_BIDDER { | ||
| bidder.extend(expand_trusted_server_bidders(&self.config.bidders, params)); | ||
| bidder.remove("aps"); |
There was a problem hiding this comment.
🔧 wrench — This bidder.remove("aps") re-opens the double-sell leak it closes, via the stored-request fallback at line 1460.
When remove("aps") empties the map, the bidder.is_empty() check at line 1460 fires the stored-request fallback, handing bidder resolution back to PBS's server-side stored request — which can itself configure APS/amazon demand.
The comment just above asserts this "cannot fire for the client /auction path" because the JS adapter always injects a trustedServer entry — but that entry is now exactly what empties the map, so the documented invariant is false. An ["aps"]-only config during migration hits this (the PR's own new test blesses ["kargo", "aps"]). Verified: config.bidders=["aps"] → storedrequest:{id:"in_content_ad"}.
Fix — track that the emptiness came from APS removal and skip the fallback (drop the imp instead):
let storedrequest = if bidder.is_empty() && !removed_aps {
Some(ImpStoredRequest { id: slot.id.clone() })
} else {
if removed_aps && bidder.is_empty() {
log::warn!("prebid: slot '{}' has only APS demand — dropping imp rather than falling back to a stored request", slot.id);
}
None
};| // keys like "aps" which belong to their own separate auction provider. | ||
| let mut bidder: HashMap<String, Json> = HashMap::new(); | ||
| for (name, params) in &slot.bidders { | ||
| if name == "aps" { |
There was a problem hiding this comment.
🔧 wrench — The APS exclusion is case-sensitive, so a case variant leaks straight through to PBS.
Both guards (name == "aps" here and bidder.remove("aps") at line 1434) compare exactly. config.bidders is a free-form operator string list with no normalization, so "APS"/"Aps" misses both guards, hits the config.bidders.contains(name) branch, and is inserted → APS demand sold twice. Verified: config.bidders=["kargo","APS"] → ext.prebid.bidder.APS present.
if name.eq_ignore_ascii_case("aps") { continue; }
// and after the expand:
bidder.retain(|b, _| !b.eq_ignore_ascii_case("aps"));Better still: reject aps in [prebid].bidders/client_side_bidders at config-validation time — that fixes this, the stored-request fallback, and the head-insert leak (head_inserts still ships config.bidders verbatim to window.__tsjs_prebid) at the source.
| return; | ||
| } | ||
|
|
||
| registerApsPrebidRenderer(bid['adId'], bid['adUnitCode'], renderer, bid['ttl'], { |
There was a problem hiding this comment.
🔧 wrench — Failed renderer registration silently produces a blank, impression-consuming bid.
registerApsPrebidRenderer's boolean return is discarded and APS_RENDERER_FIELD is deleted unconditionally (line 521). When validateApsRenderer rejects the descriptor (malformed envelope, non-canonical base64, cross-check mismatch), the bid survives in the auction with ad: '' (set at line 209) and no renderer anywhere.
Scenario: that bid wins GAM. The bridge's getApsPrebidRenderer(adId) misses, falls through, finds no match in window.tsjs.bids (client-side Prebid bids are never there), and returns without stopImmediatePropagation(). Prebid's own handler then renders ad: '' — a blank slot that has consumed the impression and reported a win.
Durable fix: have auctionBidsToPrebidBids (line 209) drop the bid entirely when bid.renderer is present but fails validateApsRenderer, so an unrenderable bid never enters the auction. At minimum, surface the failure:
if (!registerApsPrebidRenderer(bid['adId'], bid['adUnitCode'], renderer, bid['ttl'], { ... })) {
log.warn('[tsjs-prebid] APS renderer registration rejected', { adId: bid['adId'] });
}| return value as unknown as ApsRendererV1; | ||
| } | ||
|
|
||
| function decodeStandardBase64(value: string): Uint8Array | undefined { |
There was a problem hiding this comment.
♻️ refactor — JS accepts non-canonical base64 that the Rust renderer rejects → 10s blank instead of an immediate skip.
decodeStandardBase64 validates charset and padding shape but omits the re-encode round-trip that aps.rs:76 performs (btoa(binary) !== renderer.aaxResponse). Non-canonical trailing bits (e.g. QR== where QQ== is canonical) decode identically here but fail the Rust round-trip: parent validates OK → creates the iframe → renderer document rejects → never posts ready or failed → the parent's 10s timeout fires fail(). A synchronous rejection here would let the caller skip the bid immediately.
const binary = atob(value);
if (binary.length > MAX_RENDER_ENVELOPE_BYTES) return undefined;
if (btoa(binary) !== value) return undefined;
return Uint8Array.from(binary, (c) => c.charCodeAt(0));| * Static source executed by Prebid Universal Creative's dynamic-renderer frame. | ||
| * It reads only the validated descriptor and trusted absolute endpoint URL from data. | ||
| */ | ||
| export const APS_UNIVERSAL_CREATIVE_RENDERER = |
There was a problem hiding this comment.
♻️ refactor — The UC renderer string hardcodes constants that exist as exported symbols. APS_UNIVERSAL_CREATIVE_RENDERER embeds the renderer path, both message names, and the full sandbox token list as literals — a duplicate of APS_RENDERER_PATH, RENDERER_READY_MESSAGE/RENDERER_FAILED_MESSAGE, and APS_RENDERER_SANDBOX (themselves a copy of the Rust constants). A change to APS_RENDERER_PATH breaks the UC path silently with no compile error. Build the string with interpolation (e.g. p.pathname!==${JSON.stringify(APS_RENDERER_PATH)}) so the copies can't drift.
| log.warn('APS renderer: frame load failed'); | ||
| }; | ||
| const commit = (): void => { | ||
| if (settled || activeFrames.get(container) !== iframe || !iframe.isConnected) return; |
There was a problem hiding this comment.
🤔 thinking — Superseded pending frame leaks its message listener for up to 10s. commit() early-returns when activeFrames.get(container) !== iframe before calling cleanup(), so a superseded frame's message listener and timer survive until its own 10s timeout fires fail(). Bounded and not exploitable (the nonce gates the handler), but on a fast-refreshing slot this accumulates listeners. Have the supersede branch settle and clean up rather than just returning.
| ); | ||
| }); | ||
|
|
||
| test("rejects same-origin creative URLs during static validation", async ({ |
There was a problem hiding this comment.
🤔 thinking — This same-origin rejection test is effectively vacuous. It asserts runnerRequests === 0 after a fixed page.waitForTimeout(100) with no positive control, and drives a hand-rolled testPage() harness that performs no validation instead of renderApsCreative — so it would report green if the guard broke or were deleted. (I did confirm the guard at aps.rs:74 is live — location.origin returns the URL origin, not "null", in the opaque-origin renderer — via a browser test; the check is real, this test just doesn't exercise it.) Add a positive control (a valid cross-origin descriptor reaching runnerRequests === 1 via expect.poll), then assert the same-origin descriptor keeps it there; better, drive it through renderApsCreative. Related nits nearby: the sandbox assertions at :464/:555 assert the harness's own input rather than product behavior, and there's no JS-side boundary coverage for the size caps.
Summary
/e/pb/bid.Changes
crates/trusted-server-core/src/integrations/aps.rscrates/trusted-server-core/src/auction/*adm.crates/trusted-server-core/src/integrations/prebid.rscrates/trusted-server-core/src/{publisher.rs,creative_opportunities.rs}crates/trusted-server-js/lib/src/integrations/aps/render.tscrates/trusted-server-js/lib/src/integrations/gpt/index.tscrates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.tstrusted-server.example.tomland integration fixturesaccount_id, the APS OpenRTB endpoint, and default-offallow_script_creatives.docs/**,CHANGELOG.md,TESTING.mdCloses
Closes #764
Test plan
cargo test-fastly && cargo test-axumcargo clippy-fastly && cargo clippy-axumcargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute servecargo test-cloudflare,cargo test-spin, all Cloudflare/Spin clippy aliases, 13 cross-adapter parity tests, browser TypeScript compilation, full Next.js/WordPress Playwright suite, TSJS bundle build, and VitePress buildChecklist
unwrap()in production code — useexpect("should ...")logmacros (notprintln!)Rollout note
Broad production rollout and production
tagtype=scriptenablement remain gated on controlled APS-account validation and APS account-team confirmation. Script creatives remain disabled by default.