Skip to content

Add first-class APS OpenRTB integration#918

Open
ChristianPavilonis wants to merge 5 commits into
mainfrom
issue-764-aps-openrtb
Open

Add first-class APS OpenRTB integration#918
ChristianPavilonis wants to merge 5 commits into
mainfrom
issue-764-aps-openrtb

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • Replace the legacy APS contextual/TAM request path with a first-class APS OpenRTB provider using /e/pb/bid.
  • Carry minimized, typed APS renderer state through direct auctions, navigation/page-bids, mediation, and GPT/Universal Creative rendering.
  • Add default-off script-creative gating, opaque-origin rendering, nonce-bound messaging, PBS coexistence safeguards, migration docs, and regression coverage.

Changes

File Change
crates/trusted-server-core/src/integrations/aps.rs Builds independent APS OpenRTB requests, validates and reduces responses, creates minimized renderer envelopes, and serves the sandboxed static renderer.
crates/trusted-server-core/src/auction/* Adds typed APS renderer metadata, preserves bid identity through floors and orchestration, and exposes APS winners without adm.
crates/trusted-server-core/src/integrations/prebid.rs Prevents APS from also being sent to Prebid Server for Trusted Server APS cohorts.
crates/trusted-server-core/src/{publisher.rs,creative_opportunities.rs} Carries APS renderer data through navigation/page-bids while retaining legacy slot configuration only for compatibility.
crates/trusted-server-js/lib/src/integrations/aps/render.ts Validates exact renderer descriptors and envelopes, creates opaque sandboxed frames, and enforces fragment-bound nonces and readiness acknowledgements.
crates/trusted-server-js/lib/src/integrations/gpt/index.ts Routes APS winners through the Universal Creative dynamic-renderer bridge without cache fetches, beacons, or native APS display calls.
crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts Verifies CSP sandboxing, nonce and replay protections, same-origin rejection, restrictive-CSP compatibility, and parent-DOM isolation.
trusted-server.example.toml and integration fixtures Adds canonical account_id, the APS OpenRTB endpoint, and default-off allow_script_creatives.
docs/**, CHANGELOG.md, TESTING.md Documents migration, security boundaries, orchestration behavior, rollout gates, and validation coverage.

Closes

Closes #764

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: cargo 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 build

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses project log macros (not println!)
  • New code has tests
  • No secrets or credentials committed

Rollout note

Broad production rollout and production tagtype=script enablement remain gated on controlled APS-account validation and APS account-team confirmation. Script creatives remain disabled by default.

Comment thread crates/trusted-server-js/lib/test/integrations/aps/render.test.ts Fixed
Comment thread crates/trusted-server-js/lib/test/integrations/aps/render.test.ts Fixed
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review July 17, 2026 02:07

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add APS metadata to result of the auction?

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-form config.bidders slips through to PBS and is sold twice. Separately, head_inserts serializes config.bidders/client_side_bidders verbatim into window.__tsjs_prebid, so aps still ships to the browser — the safeguard is server-side only, and client_side_bidders is never filtered at all. The durable fix for this and the stored-request finding is to reject aps in [prebid].bidders/client_side_bidders at config-validation time.

Non-blocking

🤔 thinking

  • Full client-controlled Referer path+query now enters the bidstream (formats.rs:93-116): site.page changed from a bare https://{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_mock mediation is last-write-wins and the new test enshrines it (adserver_mock.rs:107, :899): build_bid_index keys 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_id now exists and would disambiguate; the mock only echoes crid. At least raise the collision log from debug! to warn!.
  • providers.aps is now write-only and fails silently (creative_opportunities.rs:366,378): the field is a genuine deny_unknown_fields deser-compat shim (removing it hard-fails existing TOML), but to_ad_slot no longer reads it, so an operator who sets aps.slot_id expecting 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. Prefer as_aps(&self) -> Option<&ApsRendererV1>. Note the generic bid.bid_id field this PR adds already carries the same value, so publisher.rs could derive hb_adid renderer-agnostically.

📝 note

  • Undocumented /auction wire-contract change: for renderer bids, crid goes from always-present {bidder}-creative to bid.creative_id (absent when the bidder omits crid), id from {bidder}-{slot} to the upstream bid id, and adm is omitted entirely. tsjs absorbs all three, but POST /auction is 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 of renderApsCreative, 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_id is the only renderer field without a length cap (aps.rs:682) — bounded by the 2 MiB body cap but inconsistent with account_id/creative_url.
  • dropped_bid_count and drop_reasons don't reconcile (aps.rs:776-799) — price-losers increment the count with no reason entry; the test asserts dropped=1, drop_reasons={}.
  • auction/README.md:120 ASCII box right border is off by one column.
  • trusted-server.example.toml regressed a fictional aps.example.com/e/dtb/bid to a real vendor host; several docs/tests use real *.amazon-adsystem.com endpoints against the project's "example.com only" rule. These are functionally-required vendor endpoints (not customer data or credentials) and amazon-adsystem predates 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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinkingto_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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 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");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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'], {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ 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 =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 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 ({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 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 livelocation.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add the Amazon APS Prebid Adapter as first class integration

3 participants