diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4f45fbeb9c..2c8cacd32d 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -78,42 +78,54 @@ const APS_RENDERER_DOCUMENT: &str = r#" 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=''; @@ -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); @@ -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" + ); + } } diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts index e7ee7c1c7a..acccd7ba81 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts @@ -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 { @@ -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: '
', + }), + ); + await page.route(outerCreativeUrl, (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: ``, + }), + ); + // 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: "silent renderer", + }), + ); + + 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; + 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, }) => { @@ -566,7 +711,7 @@ parent.postMessage(JSON.stringify({ expect.objectContaining({ message: "Prebid Response", adId: result.acceptedAdId, - rendererVersion: 4, + rendererVersion: universalCreativeRendererVersion(), apsRenderer, }), ); @@ -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; + }>; + } + ).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 }) => { diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 3407d487a0..f5459be7bd 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -181,12 +181,42 @@ export type GptDiagnosticsTrustedServerOpportunity = | 'unrenderable_candidate' | 'no_candidate'; +/** + * Every safe failure category observed while obtaining or posting creative markup. + * + * This array is the single source of truth: `GptDiagnosticsCreativeFailure` is + * derived from it, and the diagnostics store validates against it at runtime. + * Adding a member here is sufficient for the store to accept it; a + * compile-time union and a hand-written runtime allowlist would otherwise + * drift, and a dropped reason is silent. An `aps_` member also needs a wire + * name in the render path's reason map to be reachable, and every member needs + * a case in the overlay's exhaustive switch, which is a compile error by design. + * + * The `aps_` members cover the APS render path, where a blank slot is otherwise + * indistinguishable from a filled one: Ad Manager reports a non-empty 1x1 + * render whether or not the creative ever drew. + */ +export const GPT_DIAGNOSTICS_CREATIVE_FAILURES = [ + 'missing_render_source', + 'cache_fetch_failed', + 'invalid_cache_payload', + 'response_post_failed', + // Reported by the sandboxed renderer document and relayed by the creative. + 'aps_bad_hash', + 'aps_nonce_mismatch', + 'aps_descriptor_keys', + 'aps_descriptor_fields', + 'aps_descriptor_envelope', + 'aps_runner_script_error', + // Observed by the Universal Creative source around its renderer frame. + 'aps_frame_timeout', + 'aps_frame_load_error', + 'aps_frame_reported_failure', + 'aps_unknown', +] as const; + /** A safe failure category observed while obtaining or posting creative markup. */ -export type GptDiagnosticsCreativeFailure = - | 'missing_render_source' - | 'cache_fetch_failed' - | 'invalid_cache_payload' - | 'response_post_failed'; +export type GptDiagnosticsCreativeFailure = (typeof GPT_DIAGNOSTICS_CREATIVE_FAILURES)[number]; /** Delivery evidence derived for a GPT request cycle. */ export type GptDiagnosticsDelivery = diff --git a/crates/trusted-server-js/lib/src/integrations/aps/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts index d794b86f33..6cecf7a1f8 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,6 +1,11 @@ import { log } from '../../core/log'; import { findSlot } from '../../core/render'; -import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; +import type { + ApsPrebidRendererEntry, + ApsRendererV1, + GptDiagnosticsCreativeFailure, + TsjsApi, +} from '../../core/types'; export const APS_RENDERER_PATH = '/integrations/aps/renderer'; export const APS_RENDERING_MODE_ATTRIBUTE_NAME = 'data-ts-aps-rendering-mode'; @@ -9,7 +14,7 @@ export const APS_PREBID_CREATIVE_RUNNER_URL = export const APS_NATIVE_RENDERER_TIMEOUT_MS = 10_000; export const APS_RENDERER_SANDBOX = 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; -export const APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = 4; +export const APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = 5; const MAX_ACCOUNT_ID_BYTES = 1024; const MAX_CREATIVE_ID_BYTES = 1024; @@ -32,7 +37,77 @@ const activeFrames = new WeakMap(); const pendingFrameCancels = new WeakMap void>(); const RENDERER_READY_MESSAGE = 'trusted-server/aps/renderer-ready'; const RENDERER_FAILED_MESSAGE = 'trusted-server/aps/renderer-failed'; -const RENDERER_READY_TIMEOUT_MS = 10_000; +/** + * How long a renderer frame has to acknowledge before its render is failed. + * + * Exported because the consumed-ad-ID tombstone has to outlive this window: the + * frame starts its timer only after the capability round trip, so a timeout + * relay arrives later than this many milliseconds after the ad ID was consumed. + */ +export const RENDERER_READY_TIMEOUT_MS = 10_000; + +/** + * Message the Universal Creative frame relays to the top window when an APS + * render never completes. + * + * The creative frame is cross-origin, so the top-window listener treats every + * field as untrusted and resolves the reason through + * [`apsRenderFailureReason`] before recording it. The relay is + * diagnostics-only and never influences creative delivery. + */ +export const APS_RENDER_FAILED_MESSAGE = 'trusted-server/aps/render-failed'; + +/** + * Wire reasons the render path can emit, mapped onto safe diagnostic categories. + * + * Built on a null prototype so a hostile `__proto__`, `constructor`, or + * `toString` relayed by the cross-origin creative frame resolves to `undefined` + * rather than an inherited member. + */ +let apsRenderFailureReasons: Readonly> | undefined; + +/** + * Build the reason allowlist on first use. + * + * Deferred so the table is not a module-level side effect: the Prebid shim + * imports two unrelated helpers from this module, and `Object.freeze` at module + * scope would defeat tree-shaking and carry this table into that bundle. + */ +function renderFailureReasons(): Readonly> { + apsRenderFailureReasons ??= Object.freeze( + Object.assign( + Object.create(null) as Record, + { + bad_hash: 'aps_bad_hash', + nonce_mismatch: 'aps_nonce_mismatch', + descriptor_keys: 'aps_descriptor_keys', + descriptor_fields: 'aps_descriptor_fields', + descriptor_envelope: 'aps_descriptor_envelope', + amazon_script_error: 'aps_runner_script_error', + frame_timeout: 'aps_frame_timeout', + frame_load_error: 'aps_frame_load_error', + frame_reported_failure: 'aps_frame_reported_failure', + unknown: 'aps_unknown', + } as const + ) + ); + return apsRenderFailureReasons; +} + +/** + * Resolve a relayed render failure reason to a safe diagnostic category. + * + * Returns `undefined` for anything not on the allowlist, so an unrecognized or + * hostile value from the cross-origin creative frame is dropped instead of + * being recorded. + * + * @example + * apsRenderFailureReason('frame_timeout'); // 'aps_frame_timeout' + * apsRenderFailureReason('__proto__'); // undefined + */ +export function apsRenderFailureReason(value: unknown): GptDiagnosticsCreativeFailure | undefined { + return typeof value === 'string' ? renderFailureReasons()[value] : undefined; +} const MAX_PREBID_RENDERER_ENTRIES = 256; const DEFAULT_PREBID_RENDERER_TTL_SECONDS = 300; const MAX_PREBID_RENDERER_TTL_SECONDS = 3600; @@ -672,10 +747,14 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative if (activeFrames.get(container) === iframe) activeFrames.delete(container); iframe.remove(); }; - const fail = (): void => { + // `reason` is the allowlisted category when the frame named one, so the log + // distinguishes a rejected descriptor from a timeout instead of reporting + // every cause as a load failure. Recording it against a diagnostics attempt + // depends on the attempt-lifecycle work tracked separately. + const fail = (reason?: GptDiagnosticsCreativeFailure): void => { if (settled) return; cancel(); - log.warn('APS renderer: frame load failed'); + log.warn(`APS renderer: frame failed (${reason ?? 'unspecified'})`); }; const commit = (): void => { if (settled || activeFrames.get(container) !== iframe || !iframe.isConnected) return; @@ -688,12 +767,22 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative iframe.style.display = ''; }; function receive(event: MessageEvent): void { - if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) { + if (event.source !== iframe.contentWindow || !isRecord(event.data)) return; + const { message, nonce: sent } = event.data; + if (message === RENDERER_READY_MESSAGE) { + if (hasExactKeys(event.data, ['message', 'nonce']) && sent === nonce) commit(); return; } - if (event.data.nonce !== nonce) return; - if (event.data.message === RENDERER_READY_MESSAGE) commit(); - else if (event.data.message === RENDERER_FAILED_MESSAGE) fail(); + // A failure message also carries a `reason`. `bad_hash` is the only report + // a frame sends before it has learned a nonce, so accepting a nonce-less + // failure is narrowed to that reason: anything else running in the frame + // could otherwise tear it down without knowing the nonce. + if ( + message === RENDERER_FAILED_MESSAGE && + (sent === nonce || (sent === undefined && event.data.reason === 'bad_hash')) + ) { + fail(apsRenderFailureReason(event.data.reason)); + } } window.addEventListener('message', receive); @@ -714,9 +803,9 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative }, { once: true } ); - iframe.addEventListener('error', fail, { once: true }); + iframe.addEventListener('error', () => fail('aps_frame_load_error'), { once: true }); - const timeoutId = window.setTimeout(fail, RENDERER_READY_TIMEOUT_MS); + const timeoutId = window.setTimeout(() => fail('aps_frame_timeout'), RENDERER_READY_TIMEOUT_MS); pendingFrameCancels.set(container, cancel); container.appendChild(iframe); return true; @@ -725,8 +814,22 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative /** * Static source executed by Prebid Universal Creative's dynamic-renderer frame. * It reads only the validated descriptor and trusted absolute endpoint URL from data. + * + * Failure reports go out through Universal Creative's own `sendMessage` helper + * rather than `postMessage` on the creative window. Universal Creative runs this + * source inside a hidden frame it creates in the creative document, so a + * `postMessage` call made from here carries that hidden frame as + * `event.source`, whatever window it targets: the source is the realm of the + * running function, not the window the method is called on. The top-window + * listener authenticates a sender by matching it against iframes in the + * publisher document, which the hidden frame is not, so such a report is always + * dropped. The helper is a creative-realm function, so a report sent through it + * arrives as the creative iframe the listener already recognizes. + * + * The helper stringifies the body and stamps Universal Creative's own `adId`, + * both of which the consuming bridge already handles. */ -export const APS_UNIVERSAL_CREATIVE_RENDERER = String.raw`(function(){window.render=function(d,_h,w){return new Promise(function(resolve,reject){ +export const APS_UNIVERSAL_CREATIVE_RENDERER = String.raw`(function(){window.render=function(d,h,w){return new Promise(function(resolve,reject){ try{var r=d&&d.apsRenderer,u=d&&d.rendererUrl;if(!r||typeof u!=="string")throw new Error("invalid APS renderer data"); var p=new URL(u);if((p.protocol!=="https:"&&p.protocol!=="http:")||p.username||p.password||p.pathname!=="${APS_RENDERER_PATH}"||p.search||p.hash)throw new Error("invalid APS renderer URL"); var c=w.crypto;if(!c||typeof c.getRandomValues!=="function")throw new Error("APS renderer randomness unavailable"); @@ -734,12 +837,13 @@ var b=new Uint8Array(16);c.getRandomValues(b);var s="";for(var i=0;i, + consumedIds: Map, now: number ): void { for (const [adId, consumed] of consumedIds) { @@ -1914,7 +1929,7 @@ function pruneConsumedPrebidApsIds( } function hasConsumedPrebidApsIdCapacity( - consumedIds: Map, + consumedIds: Map, adId: string ): boolean { if (consumedIds.has(adId) || consumedIds.size < MAX_CONSUMED_PREBID_APS_IDS) return true; @@ -1924,11 +1939,20 @@ function hasConsumedPrebidApsIdCapacity( } function recordConsumedPrebidApsId( - consumedIds: Map, + consumedIds: Map, adId: string, - expiresAt: number + expiresAt: number, + adUnitCode: string ): void { - consumedIds.set(adId, { expiresAt }); + // A capability consumed with little TTL left would otherwise lose its + // tombstone before the renderer frame's timeout relay arrives, dropping the + // reason. The frame starts its timer only after the capability round trip, so + // the relay lands later than one timeout after consumption: hold the + // tombstone for twice that window rather than exactly it. Bounded by the + // frame's own timeout so the two cannot drift apart. This also keeps the ad ID + // blocked longer, which is the safer direction for a security tombstone. + const minimum = Date.now() + 2 * RENDERER_READY_TIMEOUT_MS; + consumedIds.set(adId, { expiresAt: Math.max(expiresAt, minimum), adUnitCode }); } /** @@ -1962,7 +1986,7 @@ export function installTsRenderBridge(): void { // is scoped to the slot, not the bare adId: hb_adid is not unique per bid, so // keying on it alone would let one slot block a distinct slot's render. const renderingKeys = new Set(); - const consumedPrebidApsIds = new Map(); + const consumedPrebidApsIds = new Map(); // One consumed APS ad ID per slot is sufficient: a newer bid replaces the // slot's old ad ID in `window.tsjs.bids`, so the ownership guard rejects it. const consumedServerApsBySlot = new Map(); @@ -1978,6 +2002,47 @@ export function installTsRenderBridge(): void { return; } + // Diagnostics relayed by the APS Universal Creative frame. The creative is + // cross-origin, so every field is untrusted: the reason resolves through the + // allowlist, and the sender must be a frame inside the ad unit the + // capability was served to. Recording only; it never answers the sender. + if (data['message'] === APS_RENDER_FAILED_MESSAGE) { + const failedAdId = data['adId']; + const reason = apsRenderFailureReason(data['reason']); + if (typeof failedAdId === 'string' && reason !== undefined) { + pruneConsumedPrebidApsIds(consumedPrebidApsIds, Date.now()); + const tombstone = consumedPrebidApsIds.get(failedAdId); + let failedSlotId: string | undefined; + if (tombstone) { + // The Prebid path tombstones the publisher's ad unit code, which can + // be a configured prefix rather than a DOM id. The store keys + // associations by Trusted Server slot id, so resolve the slot through + // the authenticated frame rather than treating the code as a map key: + // that resolver handles exact ids, prefixes, and ambiguity, and + // `divToSlotId` holds only resolved ids. The capability's own source + // check still gates it, so a sender outside the served ad unit is + // rejected before the slot is resolved. + if (sourceFrameForAdUnit(e.source, tombstone.adUnitCode)) { + failedSlotId = slotFrameForMessageSource(e.source)?.slotId; + } + } else { + // Server-side APS winners are served the same renderer and emit the + // same relay, but are tracked per slot. `hb_adid` is not unique per + // slot, so require the sender to sit in the slot that consumed it. + for (const [slotId, consumedAdId] of consumedServerApsBySlot) { + if (consumedAdId === failedAdId && sourceFrameForSlotId(e.source, slotId)) { + failedSlotId = slotId; + break; + } + } + } + if (failedSlotId !== undefined) { + safelyRecordCreativeFailure(safelyRecordCreativeRequest(failedSlotId), reason); + } + } + return; + } + if (data['message'] !== 'Prebid Request') return; const adId = data['adId'] as string | undefined; if (!adId) return; @@ -2008,7 +2073,12 @@ export function installTsRenderBridge(): void { const renderer = validateApsRenderer(prebidRendererEntry.renderer); if (!renderer || !hasConsumedPrebidApsIdCapacity(consumedPrebidApsIds, adId)) return; if (!consumeApsPrebidRenderer(adId, prebidRendererEntry)) return; - recordConsumedPrebidApsId(consumedPrebidApsIds, adId, prebidRendererEntry.expiresAt); + recordConsumedPrebidApsId( + consumedPrebidApsIds, + adId, + prebidRendererEntry.expiresAt, + prebidRendererEntry.adUnitCode + ); const markUsed = (): void => { try { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index 63c0b6fb3e..b9231ab8d3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -184,6 +184,26 @@ function creativeFailureFact( return 'Creative bridge failure: invalid cache payload'; case 'response_post_failed': return 'Creative bridge failure: response post failed'; + case 'aps_bad_hash': + return 'APS render failure: renderer frame received no nonce'; + case 'aps_nonce_mismatch': + return 'APS render failure: renderer nonce mismatch'; + case 'aps_descriptor_keys': + return 'APS render failure: descriptor key set rejected'; + case 'aps_descriptor_fields': + return 'APS render failure: descriptor fields rejected'; + case 'aps_descriptor_envelope': + return 'APS render failure: descriptor envelope rejected'; + case 'aps_runner_script_error': + return 'APS render failure: Amazon runner script failed to load'; + case 'aps_frame_timeout': + return 'APS render failure: renderer frame never acknowledged'; + case 'aps_frame_load_error': + return 'APS render failure: renderer frame raised a load error (rare; an HTTP error on the renderer route reports a timeout instead)'; + case 'aps_frame_reported_failure': + return 'APS render failure: renderer frame reported an unlabelled failure'; + case 'aps_unknown': + return 'APS render failure: unknown'; } } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index ca3348ae97..f6a73426d3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -16,6 +16,7 @@ import type { GptDiagnosticsTrustedServerOpportunity, Size, } from '../../core/types'; +import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../core/types'; export const MAX_DIAGNOSTIC_SLOTS = 64; export const MAX_REQUEST_CYCLES_PER_SLOT = 10; @@ -165,13 +166,10 @@ function isTrustedServerOpportunity( ); } +const CREATIVE_FAILURES: ReadonlySet = new Set(GPT_DIAGNOSTICS_CREATIVE_FAILURES); + function isCreativeFailure(reason: unknown): reason is GptDiagnosticsCreativeFailure { - return ( - reason === 'missing_render_source' || - reason === 'cache_fetch_failed' || - reason === 'invalid_cache_payload' || - reason === 'response_post_failed' - ); + return typeof reason === 'string' && CREATIVE_FAILURES.has(reason); } function validDuration(start: number | undefined, end: number | undefined): number | undefined { diff --git a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts index a5a229f5e0..904b97d489 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import envelope from '../../fixtures/aps-renderer-v1.json'; import type { ApsRendererV1 } from '../../../src/core/types'; +import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../../src/core/types'; import { log } from '../../../src/core/log'; import { APS_NATIVE_RENDERER_TIMEOUT_MS, @@ -9,8 +10,10 @@ import { APS_RENDERER_PATH, APS_RENDERER_SANDBOX, APS_RENDERING_MODE_ATTRIBUTE_NAME, + APS_RENDER_FAILED_MESSAGE, APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + apsRenderFailureReason, apsRendererUrl, dispatchApsRendering as dispatchDefaultApsRendering, getApsPrebidRenderer, @@ -724,6 +727,87 @@ describe('direct APS rendering', () => { expect(document.querySelector('#fictional-slot iframe')).toBeNull(); }); + it('tears down the frame when the renderer document reports a failure reason', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + const sent = postMessage.mock.calls[0][0] as { nonce: string }; + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-failed', + nonce: sent.nonce, + reason: 'descriptor_envelope', + }, + source: iframe.contentWindow, + }) + ); + + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + expect(document.querySelector('#fictional-slot span')).not.toBeNull(); + }); + + it('tears down the frame on a bad-hash report that carries no nonce to echo', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement; + iframe.dispatchEvent(new Event('load')); + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-failed', + nonce: undefined, + reason: 'bad_hash', + }, + source: iframe.contentWindow, + }) + ); + + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + }); + + it('ignores a failure report bearing a foreign nonce', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement; + iframe.dispatchEvent(new Event('load')); + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-failed', + nonce: 'a-foreign-nonce', + reason: 'bad_hash', + }, + source: iframe.contentWindow, + }) + ); + + expect(document.querySelector('#fictional-slot iframe')).not.toBeNull(); + }); + + it('still rejects a ready message carrying an unexpected extra key', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + const sent = postMessage.mock.calls[0][0] as { nonce: string }; + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-ready', + nonce: sent.nonce, + reason: 'unexpected', + }, + source: iframe.contentWindow, + }) + ); + + expect(iframe.style.display).toBe('none'); + }); + it('removes an unacknowledged frame without clearing publisher content', () => { vi.useFakeTimers(); try { @@ -855,3 +939,213 @@ describe('Universal Creative APS source', () => { } }); }); + +describe('APS render failure reason allowlist', () => { + it('maps every reason the renderer frame and creative source can emit', () => { + expect(apsRenderFailureReason('bad_hash')).toBe('aps_bad_hash'); + expect(apsRenderFailureReason('nonce_mismatch')).toBe('aps_nonce_mismatch'); + expect(apsRenderFailureReason('descriptor_keys')).toBe('aps_descriptor_keys'); + expect(apsRenderFailureReason('descriptor_fields')).toBe('aps_descriptor_fields'); + expect(apsRenderFailureReason('descriptor_envelope')).toBe('aps_descriptor_envelope'); + expect(apsRenderFailureReason('amazon_script_error')).toBe('aps_runner_script_error'); + expect(apsRenderFailureReason('frame_timeout')).toBe('aps_frame_timeout'); + expect(apsRenderFailureReason('frame_load_error')).toBe('aps_frame_load_error'); + expect(apsRenderFailureReason('frame_reported_failure')).toBe('aps_frame_reported_failure'); + expect(apsRenderFailureReason('unknown')).toBe('aps_unknown'); + }); + + it('resolves every mapped reason to a category the diagnostics store accepts', () => { + for (const wire of [ + 'bad_hash', + 'nonce_mismatch', + 'descriptor_keys', + 'descriptor_fields', + 'descriptor_envelope', + 'amazon_script_error', + 'frame_timeout', + 'frame_load_error', + 'frame_reported_failure', + 'unknown', + ]) { + const reason = apsRenderFailureReason(wire); + expect(GPT_DIAGNOSTICS_CREATIVE_FAILURES).toContain(reason); + } + }); + + it('rejects unlisted, inherited, and non-string reasons from the cross-origin frame', () => { + expect(apsRenderFailureReason('not_a_real_reason')).toBeUndefined(); + expect(apsRenderFailureReason('__proto__')).toBeUndefined(); + expect(apsRenderFailureReason('constructor')).toBeUndefined(); + expect(apsRenderFailureReason('toString')).toBeUndefined(); + expect(apsRenderFailureReason('hasOwnProperty')).toBeUndefined(); + expect(apsRenderFailureReason(42)).toBeUndefined(); + expect(apsRenderFailureReason(undefined)).toBeUndefined(); + expect(apsRenderFailureReason(null)).toBeUndefined(); + expect(apsRenderFailureReason({ toString: () => 'frame_timeout' })).toBeUndefined(); + }); +}); + +describe('Universal Creative APS failure relay', () => { + /** + * Stand-in for the `sendMessage` helper Universal Creative passes to + * `render`. It mirrors the real one: the body is JSON, the helper stamps its + * own `adId`, and the payload is applied last so a caller can override both. + */ + function pucHelper(adId: string): { sendMessage: (type: string, payload: object) => void } { + return { + sendMessage: (type, payload) => + window.postMessage(JSON.stringify({ message: type, adId, ...payload }), '*'), + }; + } + + function captureRelays(): { + relayed: Array>; + stop: () => void; + } { + const relayed: Array> = []; + const capture = (event: MessageEvent): void => { + const data = + typeof event.data === 'string' + ? (JSON.parse(event.data) as Record) + : (event.data as Record); + if (data && data['message'] === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + }; + window.addEventListener('message', capture); + return { relayed, stop: () => window.removeEventListener('message', capture) }; + } + + it('relays the frame failure reason through the creative window helper', async () => { + const dynamicWindow = window as unknown as { + render?: (data: Record, helper: unknown, target: Window) => Promise; + }; + const { relayed, stop } = captureRelays(); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + pucHelper('example-ad-id'), + window + ); + const iframe = document.body.querySelector('iframe')!; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + const sent = postMessage.mock.calls[0][0] as { nonce: string }; + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-failed', + nonce: sent.nonce, + reason: 'descriptor_envelope', + }, + source: iframe.contentWindow, + }) + ); + + await expect(rendered).rejects.toThrow(); + // `postMessage` is delivered on a later task than the rejection microtask. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(relayed).toEqual([ + { + message: APS_RENDER_FAILED_MESSAGE, + adId: 'example-ad-id', + reason: 'descriptor_envelope', + }, + ]); + } finally { + stop(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('reports a frame timeout when the renderer frame never acknowledges', async () => { + const dynamicWindow = window as unknown as { + render?: (data: Record, helper: unknown, target: Window) => Promise; + }; + const { relayed, stop } = captureRelays(); + vi.useFakeTimers(); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + pucHelper('timeout-ad-id'), + window + ); + rendered.catch(() => {}); + document.body.querySelector('iframe')!.dispatchEvent(new Event('load')); + + // Async advance so the queued `postMessage` dispatch task also runs. + await vi.advanceTimersByTimeAsync(10_001); + + expect(relayed).toEqual([ + { message: APS_RENDER_FAILED_MESSAGE, adId: 'timeout-ad-id', reason: 'frame_timeout' }, + ]); + } finally { + vi.useRealTimers(); + stop(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('labels a frame that fails to load', async () => { + const dynamicWindow = window as unknown as { + render?: (data: Record, helper: unknown, target: Window) => Promise; + }; + const { relayed, stop } = captureRelays(); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + pucHelper('load-error-ad-id'), + window + ); + rendered.catch(() => {}); + const iframe = document.body.querySelector('iframe') as HTMLIFrameElement & { + onerror?: () => void; + }; + iframe.onerror?.(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(relayed.map((entry) => entry['reason'])).toEqual(['frame_load_error']); + } finally { + stop(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('stays silent when Universal Creative provides no message helper', async () => { + const dynamicWindow = window as unknown as { + render?: (data: Record, helper: unknown, target: Window) => Promise; + }; + const { relayed, stop } = captureRelays(); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + undefined, + window + ); + rendered.catch(() => {}); + const iframe = document.body.querySelector('iframe') as HTMLIFrameElement & { + onerror?: () => void; + }; + iframe.onerror?.(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + // No helper means no relay, and the render still rejects rather than hanging. + expect(relayed).toEqual([]); + await expect(rendered).rejects.toThrow(); + } finally { + stop(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 540d15229a..b757b51deb 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -13,7 +13,11 @@ import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; import { APS_PREBID_CREATIVE_RUNNER_URL, APS_RENDERING_MODE_ATTRIBUTE_NAME, + APS_RENDER_FAILED_MESSAGE, + APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + RENDERER_READY_TIMEOUT_MS, } from '../../../src/integrations/aps/render'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; let publisherNativeScript: HTMLScriptElement | undefined; @@ -3486,7 +3490,7 @@ describe('installTsRenderBridge', () => { message: 'Prebid Response', adId: renderer.bidId, renderer: expect.stringContaining('window.render=function'), - rendererVersion: 4, + rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, rendererUrl: new URL('/integrations/aps/renderer', window.location.origin).href, apsRenderer: renderer, width: 300, @@ -3663,6 +3667,339 @@ describe('installTsRenderBridge', () => { foreignIframe.remove(); }); + it('records a relayed APS render failure against a real diagnostics store', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'prebid-relay-ad-id'; + const store = new GptDiagnosticsStore({ defer: () => undefined }); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never; + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed: vi.fn(), + }, + }; + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const slot = { + getSlotElementId: () => 'div-header', + getAdUnitPath: () => '/example/div-header', + }; + // Production associates under the configured slot id and maps the div to + // it, so the ids differ here the way they do on a real page. + (window as TestWindow).tsjs.divToSlotId = { 'div-header': 'homepage_header' }; + store.recordTrustedServerOpportunity(slot, 'homepage_header', 'renderable_candidate'); + store.recordSlotRequested(slot); + + // Serve the capability so the ad ID is tombstoned with its ad unit. + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + // The creative frame then relays a render failure to the top window. + bridgeListener( + Object.assign(new Event('message'), { + data: { + message: APS_RENDER_FAILED_MESSAGE, + adId: prebidAdId, + reason: 'frame_timeout', + }, + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([ + 'aps_frame_timeout', + ]); + } finally { + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + + it('records a frame timeout relayed after a short-TTL capability would have expired', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'prebid-short-ttl-ad-id'; + const store = new GptDiagnosticsStore({ defer: () => undefined }); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never; + // The renderer entry expires almost immediately, so only the tombstone's + // own minimum keeps the ad ID known long enough to attribute the relay. + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 1_000, + markUsed: vi.fn(), + }, + }; + + const nowSpy = vi.spyOn(Date, 'now'); + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const slot = { + getSlotElementId: () => 'div-header', + getAdUnitPath: () => '/example/div-header', + }; + (window as TestWindow).tsjs.divToSlotId = { 'div-header': 'homepage_header' }; + store.recordTrustedServerOpportunity(slot, 'homepage_header', 'renderable_candidate'); + store.recordSlotRequested(slot); + + const consumedAt = Date.now(); + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + // The frame starts its own timer only after the capability round trip, so + // its timeout relay lands after consumption plus the frame timeout. + nowSpy.mockReturnValue(consumedAt + RENDERER_READY_TIMEOUT_MS + 50); + bridgeListener( + Object.assign(new Event('message'), { + data: { + message: APS_RENDER_FAILED_MESSAGE, + adId: prebidAdId, + reason: 'frame_timeout', + }, + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([ + 'aps_frame_timeout', + ]); + } finally { + nowSpy.mockRestore(); + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + + it('records a relayed APS render failure for a prefix-based ad unit', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'prebid-prefix-relay-ad-id'; + const store = new GptDiagnosticsStore({ defer: () => undefined }); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never; + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + // A configured prefix rather than a DOM id, which `divToSlotId` never + // holds as a key: the slot has to come from the authenticated frame. + adUnitCode: 'div-', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed: vi.fn(), + }, + }; + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const slot = { + getSlotElementId: () => 'div-header', + getAdUnitPath: () => '/example/div-header', + }; + (window as TestWindow).tsjs.divToSlotId = { 'div-header': 'homepage_header' }; + store.recordTrustedServerOpportunity(slot, 'homepage_header', 'renderable_candidate'); + store.recordSlotRequested(slot); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + bridgeListener( + Object.assign(new Event('message'), { + data: { + message: APS_RENDER_FAILED_MESSAGE, + adId: prebidAdId, + reason: 'frame_timeout', + }, + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([ + 'aps_frame_timeout', + ]); + } finally { + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + + it('records a relayed APS render failure for a server-side APS winner', async () => { + const renderer = apsRenderer(); + const store = new GptDiagnosticsStore({ defer: () => undefined }); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never; + (window as TestWindow).tsjs.bids.homepage_header = { + hb_adid: renderer.bidId, + hb_bidder: 'aps', + hb_pb: '1.23', + renderer, + }; + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const slot = { + getSlotElementId: () => 'div-header', + getAdUnitPath: () => '/example/div-header', + }; + (window as TestWindow).tsjs.divToSlotId = { 'div-header': 'homepage_header' }; + store.recordTrustedServerOpportunity(slot, 'homepage_header', 'renderable_candidate'); + store.recordSlotRequested(slot); + + // Serve the server-side capability, which is tracked per slot rather than + // through the Prebid renderer tombstone. + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + bridgeListener( + Object.assign(new Event('message'), { + data: { + message: APS_RENDER_FAILED_MESSAGE, + adId: renderer.bidId, + reason: 'amazon_script_error', + }, + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([ + 'aps_runner_script_error', + ]); + } finally { + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + + it('drops a relayed APS failure from a frame outside the served ad unit', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'prebid-foreign-relay-ad-id'; + const recordTrustedServerCreativeFailure = vi.fn(); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = { + recordTrustedServerOpportunity: vi.fn(), + recordTrustedServerCreativeRequest: vi.fn(() => 3), + recordTrustedServerCreativeResponse: vi.fn(), + recordTrustedServerCreativeFailure, + } as never; + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed: vi.fn(), + }, + }; + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + recordTrustedServerCreativeFailure.mockClear(); + + // A frame that is not inside the served ad unit cannot forge a failure, + // even though it names a live ad ID and an allowlisted reason. + const foreignIframe = document.createElement('iframe'); + document.body.appendChild(foreignIframe); + bridgeListener( + Object.assign(new Event('message'), { + data: { message: APS_RENDER_FAILED_MESSAGE, adId: prebidAdId, reason: 'frame_timeout' }, + source: foreignIframe.contentWindow, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); + foreignIframe.remove(); + } finally { + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + + it('drops a relayed APS failure whose reason is not on the allowlist', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'prebid-hostile-reason-ad-id'; + const recordTrustedServerCreativeFailure = vi.fn(); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = { + recordTrustedServerOpportunity: vi.fn(), + recordTrustedServerCreativeRequest: vi.fn(() => 4), + recordTrustedServerCreativeResponse: vi.fn(), + recordTrustedServerCreativeFailure, + } as never; + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed: vi.fn(), + }, + }; + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + recordTrustedServerCreativeFailure.mockClear(); + + for (const reason of ['__proto__', 'constructor', 'not_a_reason', 7]) { + bridgeListener( + Object.assign(new Event('message'), { + data: { message: APS_RENDER_FAILED_MESSAGE, adId: prebidAdId, reason }, + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + } + + expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); + } finally { + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + it('contract test: fails a registered APS runner without a Universal Creative response or markUsed', async () => { const renderer = apsRenderer(); const prebidAdId = 'native-prebid-decline-ad-id'; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index 52aef6a7f5..1701ba8012 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -14,6 +14,7 @@ import { TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS, type GptDiagnosticsSlotLike, } from '../../../src/integrations/gpt_diagnostics/store'; +import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../../src/core/types'; function fakeSlot(elementId: string, adUnitPath = `/example/${elementId}`): GptDiagnosticsSlotLike { return { @@ -1420,6 +1421,27 @@ describe('GptDiagnosticsStore', () => { expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent'); }); + // The runtime allowlist and the exported union are derived from one array, so + // a member that exists at compile time must also be recordable at runtime. A + // hand-written allowlist previously drifted and dropped reasons silently. + it('records every declared creative failure category', () => { + for (const reason of GPT_DIAGNOSTICS_CREATIVE_FAILURES) { + const store = new GptDiagnosticsStore({ now: () => 0, defer: () => undefined }); + const slot = fakeSlot(`declared-${reason}`); + associateSlot(store, slot, `auction-${reason}`); + store.recordSlotRequested(slot); + const attemptId = store.recordTrustedServerCreativeRequest(`auction-${reason}`)!; + + store.recordTrustedServerCreativeFailure(attemptId, reason); + + expect( + store.snapshot().slots[0].requests[0].trustedServerCreativeFailures, + `should record the \`${reason}\` category` + ).toEqual([reason]); + expect(store.snapshot().attributionIssues).toEqual([]); + } + }); + it('keeps an asynchronous response on its originating cycle after a newer refresh', () => { let now = 0; const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index af6f2e3248..7e0e104c10 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -234,6 +234,32 @@ Failures are deduplicated and retain first-observed order. Detailed URLs, cache payloads, markup, and error objects remain only in existing operational logging and do not enter diagnostics. +### APS render failures + +An APS bid that wins targeting is served as a 1x1 universal creative that resizes +itself only once the creative draws, so Ad Manager reports a non-empty render whether +or not anything was drawn. These categories name the guard that stopped the render, so +a blank slot is distinguishable from a delivered one. + +They are relayed diagnostics rather than bridge failures: the sandboxed renderer +document or the Universal Creative source observes the failure and reports it, the +reason is validated against a fixed allowlist, and the sender is authenticated against +the slot the capability was served to. A reason never carries creative or descriptor +content. + +| Failure | Observed by | Meaning | +| ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `aps_bad_hash` | Renderer document | The frame loaded without a usable nonce fragment, so no descriptor was accepted. | +| `aps_nonce_mismatch` | Renderer document | A descriptor arrived bearing a nonce other than the one the frame was opened with. | +| `aps_descriptor_keys` | Renderer document | The descriptor's key set did not match either accepted shape. | +| `aps_descriptor_fields` | Renderer document | A descriptor field was absent, the wrong type, or over its size limit. | +| `aps_descriptor_envelope` | Renderer document | The encoded bid envelope failed to decode or disagreed with the descriptor. | +| `aps_runner_script_error` | Renderer document | Amazon's `prebid-creative.js` failed to load, so the creative was never rendered. | +| `aps_frame_timeout` | Universal Creative source | The renderer frame never acknowledged within its timeout. A missing or erroring renderer route also surfaces here, because an iframe does not raise a load error for an HTTP status. | +| `aps_frame_load_error` | Universal Creative source | The renderer frame raised a load error. Rare; see the timeout row. | +| `aps_frame_reported_failure` | Universal Creative source | The frame reported a failure without naming a category. | +| `aps_unknown` | Universal Creative source | The frame reported an empty reason. A reason the top window does not recognize, which can happen when the renderer document and the script bundle are on different versions, is dropped rather than recorded. | + ### Source-neutral GAM facts `slotRenderEnded` may expose line item, order, advertiser, creative, yield-group, and