Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b72f6df
Report a reason when an APS creative render fails
aram356 Oct 12, 2026
9610979
Record APS render failures in GPT diagnostics
aram356 Oct 12, 2026
ceadfdc
Read the APS renderer version from its declaration in the browser spec
aram356 Oct 12, 2026
a102c12
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 12, 2026
3590f32
Merge branch 'main' into feature/aps-render-failure-reasons
prk-Jr Oct 13, 2026
bdf63d3
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 13, 2026
8e31ebe
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 14, 2026
a90f2f8
Deliver the APS render reason to diagnostics
aram356 Oct 15, 2026
b147fe3
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 21, 2026
602fafa
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 22, 2026
64a0545
Resolve a relayed APS failure through the authenticated frame
aram356 Oct 22, 2026
fcb6600
Hold an APS tombstone past the frame's timeout relay
aram356 Oct 22, 2026
cb3ac7d
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 23, 2026
da70203
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 26, 2026
7c9129e
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 27, 2026
d5df586
Bind an APS failure report to the frame that consumed the capability
aram356 Oct 27, 2026
bdb555f
Merge branch 'main' into feature/aps-render-failure-reasons
aram356 Oct 27, 2026
246a9dd
Bind server-side APS failure reports to the served frame
aram356 Oct 29, 2026
89bbddc
Merge remote-tracking branch 'origin/main' into feature/aps-render-fa…
aram356 Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 79 additions & 19 deletions crates/trusted-server-core/src/integrations/aps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,42 +78,54 @@ const APS_RENDERER_DOCUMENT: &str = r#"<!doctype html>
var match=/^#tsaps=([A-Za-z0-9_-]{22,128})$/.exec(location.hash);
var expected=match&&match[1];
try{history.replaceState(null,'',location.pathname+location.search);}catch(_error){}
if(!expected)return;
var reported=false;
function report(reason,nonce){
if(reported)return;
reported=true;
try{parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:nonce,reason:reason},'*');}catch(_error){}
}
if(!expected){report('bad_hash');return;}
function keys(value,expectedKeys){
if(!value||typeof value!=='object'||Array.isArray(value))return false;
var actual=Object.keys(value).sort();
return actual.length===expectedKeys.length&&actual.every(function(key,index){return key===expectedKeys[index];});
}
function validRenderer(renderer){
function rendererProblem(renderer){
if(!keys(renderer,['aaxResponse','accountId','bidId','creativeId','creativeUrl','height','tagType','type','version','width'])&&
!keys(renderer,['aaxResponse','accountId','bidId','creativeUrl','height','tagType','type','version','width']))return false;
if(renderer.type!=='aps'||renderer.version!==1||typeof renderer.accountId!=='string'||!renderer.accountId||new TextEncoder().encode(renderer.accountId).length>1024)return false;
if(typeof renderer.bidId!=='string'||!renderer.bidId||!Number.isInteger(renderer.width)||renderer.width<=0||!Number.isInteger(renderer.height)||renderer.height<=0)return false;
if(Object.prototype.hasOwnProperty.call(renderer,'creativeId')&&(typeof renderer.creativeId!=='string'||!renderer.creativeId||new TextEncoder().encode(renderer.creativeId).length>1024))return false;
if(renderer.tagType!=='iframe'&&renderer.tagType!=='script')return false;
if(typeof renderer.creativeUrl!=='string'||new TextEncoder().encode(renderer.creativeUrl).length>4096)return false;
if(typeof renderer.aaxResponse!=='string'||!renderer.aaxResponse||renderer.aaxResponse.length>349528)return false;
!keys(renderer,['aaxResponse','accountId','bidId','creativeUrl','height','tagType','type','version','width']))return 'descriptor_keys';
if(renderer.type!=='aps'||renderer.version!==1||typeof renderer.accountId!=='string'||!renderer.accountId||new TextEncoder().encode(renderer.accountId).length>1024)return 'descriptor_fields';
if(typeof renderer.bidId!=='string'||!renderer.bidId||!Number.isInteger(renderer.width)||renderer.width<=0||!Number.isInteger(renderer.height)||renderer.height<=0)return 'descriptor_fields';
if(Object.prototype.hasOwnProperty.call(renderer,'creativeId')&&(typeof renderer.creativeId!=='string'||!renderer.creativeId||new TextEncoder().encode(renderer.creativeId).length>1024))return 'descriptor_fields';
if(renderer.tagType!=='iframe'&&renderer.tagType!=='script')return 'descriptor_fields';
if(typeof renderer.creativeUrl!=='string'||new TextEncoder().encode(renderer.creativeUrl).length>4096)return 'descriptor_fields';
if(typeof renderer.aaxResponse!=='string'||!renderer.aaxResponse||renderer.aaxResponse.length>349528)return 'descriptor_fields';
try{
var url=new URL(renderer.creativeUrl);
if(url.protocol!=='https:'||url.username||url.password)return false;
if(url.protocol!=='https:'||url.username||url.password)return 'descriptor_envelope';
var binary=atob(renderer.aaxResponse);
if(binary.length>262144||btoa(binary)!==renderer.aaxResponse)return false;
if(binary.length>262144||btoa(binary)!==renderer.aaxResponse)return 'descriptor_envelope';
var bytes=Uint8Array.from(binary,function(character){return character.charCodeAt(0);});
var decoded=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes));
if(!keys(decoded,['seatbid'])||!Array.isArray(decoded.seatbid)||decoded.seatbid.length!==1)return false;
if(!keys(decoded,['seatbid'])||!Array.isArray(decoded.seatbid)||decoded.seatbid.length!==1)return 'descriptor_envelope';
var seat=decoded.seatbid[0];
if(!keys(seat,['bid'])||!Array.isArray(seat.bid)||seat.bid.length!==1)return false;
if(!keys(seat,['bid'])||!Array.isArray(seat.bid)||seat.bid.length!==1)return 'descriptor_envelope';
var bid=seat.bid[0];
if(!keys(bid,['ext','h','id','price','w'])||!keys(bid.ext,['creativeurl','tagtype']))return false;
return bid.id===renderer.bidId&&bid.w===renderer.width&&bid.h===renderer.height&&
if(!keys(bid,['ext','h','id','price','w'])||!keys(bid.ext,['creativeurl','tagtype']))return 'descriptor_envelope';
if(bid.id===renderer.bidId&&bid.w===renderer.width&&bid.h===renderer.height&&
bid.ext.creativeurl===renderer.creativeUrl&&bid.ext.tagtype===renderer.tagType&&
typeof bid.price==='number'&&Number.isFinite(bid.price)&&bid.price>=0;
}catch(_error){return false;}
typeof bid.price==='number'&&Number.isFinite(bid.price)&&bid.price>=0)return undefined;
return 'descriptor_envelope';
}catch(_error){return 'descriptor_envelope';}
}
function receive(event){
// The source check stays first: a frame that cannot be reached by an
// unrelated sender cannot have its single report consumed by one either.
if(event.source!==parent)return;
var message=event.data;
if(!keys(message,['nonce','renderer'])||message.nonce!==expected||!validRenderer(message.renderer))return;
if(!keys(message,['nonce','renderer']))return;
if(message.nonce!==expected){report('nonce_mismatch',expected);return;}
var problem=rendererProblem(message.renderer);
if(problem){report(problem,message.nonce);return;}
removeEventListener('message',receive);
var acceptedNonce=expected;
expected='';
Expand All @@ -128,7 +140,7 @@ function receive(event){
var script=document.createElement('script');
script.src='https://client.aps.amazon-adsystem.com/prebid-creative.js';
script.onload=function(){parent.postMessage({message:'trusted-server/aps/renderer-ready',nonce:acceptedNonce},'*');};
script.onerror=function(){parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:acceptedNonce},'*');};
script.onerror=function(){report('amazon_script_error',acceptedNonce);};
document.head.appendChild(script);
}
addEventListener('message',receive);
Expand Down Expand Up @@ -3308,4 +3320,52 @@ mod tests {
assert!(APS_RENDERER_CSP.contains("sandbox allow-forms"));
assert!(!APS_RENDERER_CSP.contains("allow-same-origin"));
}

/// Guards the properties of the failure report that a substring check can
/// actually establish.
///
/// The browser spec evaluates the document and asserts `bad_hash`,
/// `nonce_mismatch`, and `descriptor_keys`; `descriptor_fields`,
/// `descriptor_envelope`, and `amazon_script_error` are not executed
/// anywhere yet. The assertions kept here are the ones that stay meaningful
/// against a string: that a reason is a fixed category rather than
/// attacker-influenced content, that reporting is one-shot, and that the
/// frame never answers the sender.
#[test]
fn renderer_document_reports_a_fixed_reason_category() {
// A reason is a literal category, never a copy of the rejected input.
assert!(
!APS_RENDERER_DOCUMENT.contains("JSON.stringify(renderer)"),
"should never echo the rejected descriptor"
);
assert!(
!APS_RENDERER_DOCUMENT.contains("reason:message"),
"should never echo the received message as a reason"
);

// Reporting is one-shot, so a sender cannot flood the parent.
assert!(
APS_RENDERER_DOCUMENT.contains("if(reported)return"),
"should report at most one reason per frame"
);

// The report goes to the parent, never back to the sender.
assert!(
!APS_RENDERER_DOCUMENT.contains("event.source.postMessage"),
"should never answer the sender"
);

// A non-parent sender is still dropped before any reporting happens, so
// an unrelated window cannot consume the frame's single report.
let source_check = APS_RENDERER_DOCUMENT
.find("event.source!==parent")
.expect("should check the sender before handling a message");
let nonce_report = APS_RENDERER_DOCUMENT
.find("report('nonce_mismatch'")
.expect("should report a nonce mismatch");
assert!(
source_check < nonce_report,
"should check the sender before reporting anything"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ const PUC_BANNER = readFileSync(
"utf8",
);

/**
* Read the renderer version from its declaration rather than restating it.
*
* The constant is bumped whenever the Universal Creative source changes, so a
* literal here turns every such change into an unrelated-looking failure in
* this spec.
*/
function universalCreativeRendererVersion(): number {
const source = readFileSync(
resolve(TSJS_CRATE, "lib/src/integrations/aps/render.ts"),
"utf8",
);
const match = /APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = (\d+)/.exec(source);
if (!match) {
throw new Error(
"could not read APS_UNIVERSAL_CREATIVE_RENDERER_VERSION from render.ts",
);
}
return Number(match[1]);
}

function clientAuctionBundlePaths() {
const manifestPath = resolve(TSJS_CRATE, "dist/prebid/manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
Expand Down Expand Up @@ -369,6 +390,130 @@ window.ucTag.renderAd(document, { adId: ${JSON.stringify(adId)}, pubUrl: ${JSON.
);
});

// Only a real browser shows which realm a relay comes from and which slot
// the authenticated frame resolves to, so this is the one test that can
// prove a reason reaches diagnostics end to end. The renderer route is made
// to fail here: the frame loads, never acknowledges, and Universal Creative
// reports its own timeout through the creative-realm helper.
test("relays an APS render failure reason to diagnostics through real PUC", async ({
page,
}) => {
const adId = "fictional-relay-ad-id";
const publisherOrigin = new URL(runtimeUrl("/")).origin;
const outerCreativeUrl = runtimeUrl("/fictional-puc-relay-shell");
const apsRenderer = descriptor("iframe");

await page.route(runtimeUrl("/aps-puc-relay-test"), (route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: '<!doctype html><div id="div-aps"></div>',
}),
);
await page.route(outerCreativeUrl, (route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: `<!doctype html><script>${PUC_BANNER}</script><script>
window.ucTag.renderAd(document, { adId: ${JSON.stringify(adId)}, pubUrl: ${JSON.stringify(publisherOrigin)} });
</script>`,
}),
);
// The renderer document never answers, so the render times out and the
// Universal Creative source reports `frame_timeout`.
await page.route(runtimeUrl("/integrations/aps/renderer"), (route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: "<!doctype html><title>silent renderer</title>",
}),
);

await page.goto(runtimeUrl("/aps-puc-relay-test"));
await page.addScriptTag({ path: clientAuctionBundlePaths().gpt });
await page.evaluate(
({ outerUrl, selectedAdId, renderer }) => {
const typedWindow = window as unknown as {
tsjs: Record<string, unknown>;
recordedFailures: Array<{
attemptId: number;
reason: string;
}>;
};
typedWindow.recordedFailures = [];
typedWindow.tsjs = {
bids: {
"aps-slot": {
hb_adid: selectedAdId,
hb_bidder: "aps",
hb_pb: "1.23",
renderer,
},
},
adSlots: [
{
id: "aps-slot",
// A configured prefix, not the resolved DOM id, so
// the slot has to come from the authenticated frame.
div_id: "div-",
gam_unit_path: "/fictional/aps",
formats: [[300, 250]],
},
],
divToSlotId: { "div-aps": "aps-slot" },
gptDiagnosticsRecorder: {
recordTrustedServerOpportunity: () => undefined,
recordTrustedServerCreativeRequest: () => 1,
recordTrustedServerCreativeResponse: () => undefined,
recordTrustedServerCreativeFailure: (
attemptId: number,
reason: string,
) =>
typedWindow.recordedFailures.push({
attemptId,
reason,
}),
},
};

const locator = document.createElement("iframe");
locator.name = "__pb_locator__";
document.body.appendChild(locator);

const slot = document.getElementById("div-aps")!;
const frame = document.createElement("iframe");
frame.id = "google_ads_iframe_fictional_relay_0";
frame.width = "1";
frame.height = "1";
frame.src = outerUrl;
slot.appendChild(frame);
},
{
outerUrl: outerCreativeUrl,
selectedAdId: adId,
renderer: apsRenderer,
},
);

// The Universal Creative timeout is 10s, so allow for it.
await expect
.poll(
() =>
page.evaluate(
() =>
(
window as unknown as {
recordedFailures: Array<{
reason: string;
}>;
}
).recordedFailures,
),
{ timeout: 20_000 },
)
.toEqual([{ attemptId: 1, reason: "aps_frame_timeout" }]);
});

test("renders a trustedServer adapter bid using Prebid's generated GAM ad ID", async ({
page,
}) => {
Expand Down Expand Up @@ -566,7 +711,7 @@ parent.postMessage(JSON.stringify({
expect.objectContaining({
message: "Prebid Response",
adId: result.acceptedAdId,
rendererVersion: 4,
rendererVersion: universalCreativeRendererVersion(),
apsRenderer,
}),
);
Expand Down Expand Up @@ -903,6 +1048,51 @@ parent.postMessage(JSON.stringify({
expect(runnerRequests).toBe(requestsBeforeInvalid);
await expect(page.locator("#malformed-slot .existing")).toHaveCount(1);

// The three rejections above are the only place the renderer document's
// wire names are checked by executing it, rather than by matching
// substrings in the Rust source. `nonce_mismatch` in particular is only
// useful if the frame echoes the nonce its parent expects, so assert the
// nonce as well as the reason.
const reportedFailures = await page.evaluate(() =>
(
window as unknown as {
apsMessages: Array<{
slotId: string;
data: Record<string, unknown>;
}>;
}
).apsMessages
.filter(
({ data }) =>
data["message"] ===
"trusted-server/aps/renderer-failed",
)
.map(({ slotId, data }) => ({
slotId,
reason: data["reason"],
nonce: data["nonce"],
})),
);
expect(reportedFailures).toEqual(
expect.arrayContaining([
{
slotId: "mismatch-slot",
reason: "nonce_mismatch",
nonce: validNonce,
},
{
slotId: "missing-fragment-slot",
reason: "bad_hash",
nonce: undefined,
},
{
slotId: "malformed-slot",
reason: "descriptor_keys",
nonce: wrongNonce,
},
]),
);

const scriptNonce = "0123456789abcdefghijkl";
await page.evaluate(
({ renderer, nonce }) => {
Expand Down
Loading
Loading