From b72f6df4ed7eaad0b3a5662b38ce16ed706d765c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:09:13 -0700 Subject: [PATCH 1/6] Report a reason when an APS creative render fails An APS bid that wins Prebid targeting is served by Ad Manager as a 1x1 universal creative that resizes itself only after the creative draws. Every guard on that render path returned silently, so a slot that never drew was indistinguishable from one that did: Ad Manager reports a non-empty 1x1 render either way, and the tester framework reports "filled". Name the guard that stopped the render instead. The sandboxed renderer document now reports bad_hash, nonce_mismatch, descriptor_keys, descriptor_fields, descriptor_envelope, and amazon_script_error on the existing failure message. Reporting is one-shot and answers through the parent, never the sender. The source check stays first, so a window that is not the parent is dropped before any reporting happens and cannot consume the frame's single report. The Universal Creative source labels its own frame_timeout and frame_load_error and relays whichever reason it holds to the top window. That relay crosses an origin boundary, so reasons resolve through a null-prototype allowlist that drops anything unlisted and leaves a hostile __proto__ or constructor as undefined. The table is built on first use so it does not become a module-level side effect that defeats tree-shaking and grows the Prebid shim, which imports two unrelated helpers from this module. The failure message now carries a third key, which the direct render path's listener rejected: it exact-matched two keys, so the frame survived hidden over publisher content until the 10s timeout rather than failing fast. That listener now accepts a reason-bearing failure, including the nonce-less bad_hash report a frame sends before it has learned a nonce. Reasons are fixed categories. A descriptor is never echoed back. --- .../src/integrations/aps.rs | 96 +++++-- .../lib/src/integrations/aps/render.ts | 94 ++++++- .../lib/test/integrations/aps/render.test.ts | 251 ++++++++++++++++++ 3 files changed, 411 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4f45fbeb9c..45dcf85cd9 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',message.nonce);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,50 @@ 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. + /// + /// Reachability of each reason is covered on the JavaScript side, where the + /// document is evaluated rather than read as text. 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-js/lib/src/integrations/aps/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts index d794b86f33..61a27c2d08 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; @@ -33,6 +38,69 @@ 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; + +/** + * 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; @@ -688,12 +756,15 @@ 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`, and the frame reports + // `bad_hash` before it has learned a nonce to echo back. + if (message === RENDERER_FAILED_MESSAGE && (sent === nonce || sent === undefined)) fail(); } window.addEventListener('message', receive); @@ -734,12 +805,13 @@ var b=new Uint8Array(16);c.getRandomValues(b);var s="";for(var i=0;i { 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,170 @@ 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', () => { + it('relays the frame failure reason to the top window for diagnostics', async () => { + const dynamicWindow = window as unknown as { + render?: (data: Record, helper: unknown, target: Window) => Promise; + }; + const relayed: unknown[] = []; + const capture = (event: MessageEvent): void => { + const data = event.data as { message?: unknown } | undefined; + if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + }; + window.addEventListener('message', capture); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { adId: 'example-ad-id', apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + undefined, + 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 { + window.removeEventListener('message', capture); + 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: unknown[] = []; + const capture = (event: MessageEvent): void => { + const data = event.data as { message?: unknown } | undefined; + if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + }; + window.addEventListener('message', capture); + vi.useFakeTimers(); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { adId: 'timeout-ad-id', apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + undefined, + 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(); + window.removeEventListener('message', capture); + 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: Array<{ reason?: unknown }> = []; + const capture = (event: MessageEvent): void => { + const data = event.data as { message?: unknown; reason?: unknown } | undefined; + if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + }; + window.addEventListener('message', capture); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { adId: 'load-error-ad-id', 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)); + expect(relayed.map((entry) => entry.reason)).toEqual(['frame_load_error']); + } finally { + window.removeEventListener('message', capture); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); +}); From 961097922e4995ea01ea78c820130a49a93510a0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:09:25 -0700 Subject: [PATCH 2/6] Record APS render failures in GPT diagnostics The reasons the render path now emits had nowhere to land. The creative failure union in types.ts is the compile-time contract, but the runtime contract was a hand-written allowlist in the diagnostics store, and the overlay rendered facts from a separate exhaustive switch. Three lists, kept in agreement by hand, and a reason missing from the store's list was dropped silently. Derive the union from one const array and validate against the same array, so a category that exists at compile time is recordable at runtime by construction. The overlay keeps its exhaustive switch with no default, which is what makes a new category a compile error there rather than an undefined fact. The bridge consumes the relay the creative frame posts to the top window. 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, so a frame that merely knows a live ad ID cannot forge a failure. The ad ID is not secret from the ad-serving chain, since it is published as hb_adid targeting. The consumed-ad-ID tombstone carries the ad unit it was served to for exactly that check. Recording only; the branch never answers the sender and never influences delivery. Attribution of the relayed failure to the render it belongs to still depends on the attempt lifecycle work tracked separately: a response recorded when the descriptor is posted closes the attempt before the frame reports, and the slot association API plants a request intent that belongs to the next cycle. Tests drive a real store rather than a mock recorder, so a reason that the store would reject fails the test. Both new store and bridge tests were checked against the pre-fix allowlist and the unwired relay, and fail there. --- .../trusted-server-js/lib/src/core/types.ts | 37 +++- .../lib/src/integrations/gpt/index.ts | 51 +++++- .../integrations/gpt_diagnostics/overlay.ts | 20 +++ .../src/integrations/gpt_diagnostics/store.ts | 10 +- .../lib/test/integrations/gpt/ad_init.test.ts | 162 +++++++++++++++++- .../gpt_diagnostics/store.test.ts | 22 +++ 6 files changed, 283 insertions(+), 19 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 3407d487a0..a3c2e3d227 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -181,12 +181,39 @@ 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; a compile-time union and a hand-written + * runtime allowlist would otherwise drift, and a dropped reason is silent. + * + * 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/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index fc18beb5e9..cf6e26b658 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -17,8 +17,10 @@ import type { TsjsApi, } from '../../core/types'; import { + APS_RENDER_FAILED_MESSAGE, APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + apsRenderFailureReason, apsRendererUrl, dispatchApsRendering, consumeApsPrebidRenderer, @@ -1901,11 +1903,23 @@ function safelyRecordCreativeFailure( } } +/** + * A consumed APS ad ID, retained as a security tombstone. + * + * `adUnitCode` is kept so a failure relayed later by the creative frame can be + * source-checked against the ad unit the capability was served to, rather than + * trusted because it names a live ad ID. + */ +interface ApsConsumedTombstone { + expiresAt: number; + adUnitCode: string; +} + /** Maximum number of consumed APS Prebid IDs retained as security tombstones. */ const MAX_CONSUMED_PREBID_APS_IDS = 256; function pruneConsumedPrebidApsIds( - consumedIds: Map, + consumedIds: Map, now: number ): void { for (const [adId, consumed] of consumedIds) { @@ -1914,7 +1928,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 +1938,12 @@ function hasConsumedPrebidApsIdCapacity( } function recordConsumedPrebidApsId( - consumedIds: Map, + consumedIds: Map, adId: string, - expiresAt: number + expiresAt: number, + adUnitCode: string ): void { - consumedIds.set(adId, { expiresAt }); + consumedIds.set(adId, { expiresAt, adUnitCode }); } /** @@ -1962,7 +1977,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 +1993,23 @@ 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); + if (tombstone && sourceFrameForAdUnit(e.source, tombstone.adUnitCode)) { + safelyRecordCreativeFailure(safelyRecordCreativeRequest(tombstone.adUnitCode), reason); + } + } + return; + } + if (data['message'] !== 'Prebid Request') return; const adId = data['adId'] as string | undefined; if (!adId) return; @@ -2008,7 +2040,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..29dbd3d57e 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 failed to load'; + 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/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 540d15229a..113386970a 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,10 @@ 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, } from '../../../src/integrations/aps/render'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; let publisherNativeScript: HTMLScriptElement | undefined; @@ -3486,7 +3489,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 +3666,163 @@ 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', + }; + store.recordTrustedServerOpportunity(slot, 'div-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('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 }); From ceadfdcca04cc2b4f20befa66aa8eabb4cf425e2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:26:04 -0700 Subject: [PATCH 3/6] Read the APS renderer version from its declaration in the browser spec The Universal Creative source changed, so its version constant moved 4 to 5, and the browser spec asserted the literal 4 against the Prebid Response payload. Bumping the constant is the normal consequence of touching that source, so a literal here turns every such change into a failure that looks unrelated to the change that caused it. Read the constant from render.ts instead, the way the spec already reads the built bundles from that crate. --- .../browser/tests/shared/aps-renderer.spec.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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..22e11321e7 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 { @@ -566,7 +587,7 @@ parent.postMessage(JSON.stringify({ expect.objectContaining({ message: "Prebid Response", adId: result.acceptedAdId, - rendererVersion: 4, + rendererVersion: universalCreativeRendererVersion(), apsRenderer, }), ); From a90f2f80edff288277f2832179682b992977b6f8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:36:40 -0700 Subject: [PATCH 4/6] Deliver the APS render reason to diagnostics Review found that the relay recorded nothing in a real browser. Four independent gaps each dropped the reason, and the new bridge test passed only because it used keys and a sender that production never produces. The relay was posted from the wrong realm. Universal Creative runs the renderer source inside a hidden frame it creates in the creative document, so a postMessage call made from that source carries the 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 every relay was dropped. Reports now go through Universal Creative's own sendMessage helper, which is a creative-realm function, so a report arrives as the creative iframe the listener already recognizes. Verified in headless Chromium against a page mirroring Universal Creative's structure: the previous call reports the hidden renderer frame and matches no slot iframe, the helper reports the creative iframe and matches. The Prebid path recorded under the publisher's ad unit code, which is a div id, while the store keys associations by the configured Trusted Server slot id. Whenever they differ the lookup missed, the failure was dropped, and a creative_request_without_slot attribution issue was recorded in its place, so a render failure surfaced as a misleading attribution problem. The relay now resolves the slot id through divToSlotId. Server-side APS winners are served the same renderer and emit the same relay, but are tracked per slot rather than through the Prebid tombstone, so their relays were dropped. They are now resolved too, requiring the sender to sit in the slot that consumed the ad ID because hb_adid is not unique per slot. nonce_mismatch could never be recorded: the report echoed the mismatching nonce, which no parent accepts. It now echoes the nonce the frame expects, which only the parent receives, and the parent is the window that set it. Accepting any nonce-less failure let code running inside the renderer frame tear the frame down without knowing the nonce. bad_hash is the only report a frame sends before it has learned one, so nonce-less acceptance is narrowed to that reason in both parents. A tombstone could also expire before the frame's timeout fired, dropping the reason for a capability consumed late in its TTL. Tombstones now live at least as long as that timeout, which also keeps the ad ID blocked longer. The browser spec now asserts the reasons the renderer document actually emits, including the echoed nonce. That is the only coverage that executes the document rather than matching substrings in its Rust source, and it is where the nonce_mismatch bug would have been caught. The direct render path logs the reason instead of reporting every cause as a load failure. Operator docs describe the ten categories and where each is observed. --- .../src/integrations/aps.rs | 2 +- .../browser/tests/shared/aps-renderer.spec.ts | 45 +++++++++ .../trusted-server-js/lib/src/core/types.ts | 7 +- .../lib/src/integrations/aps/render.ts | 45 +++++++-- .../lib/src/integrations/gpt/index.ts | 32 ++++++- .../integrations/gpt_diagnostics/overlay.ts | 2 +- .../lib/test/integrations/aps/render.test.ts | 95 ++++++++++++++----- .../lib/test/integrations/gpt/ad_init.test.ts | 58 ++++++++++- docs/guide/integrations/gpt-diagnostics.md | 26 +++++ 9 files changed, 268 insertions(+), 44 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 45dcf85cd9..11f0d888cb 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -123,7 +123,7 @@ function receive(event){ if(event.source!==parent)return; var message=event.data; if(!keys(message,['nonce','renderer']))return; - if(message.nonce!==expected){report('nonce_mismatch',message.nonce);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); 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 22e11321e7..2f95697e2d 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 @@ -924,6 +924,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 a3c2e3d227..f5459be7bd 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -186,8 +186,11 @@ export type GptDiagnosticsTrustedServerOpportunity = * * 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; a compile-time union and a hand-written - * runtime allowlist would otherwise drift, and a dropped reason is silent. + * 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 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 61a27c2d08..bef091ea6b 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -740,10 +740,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; @@ -762,9 +766,16 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative if (hasExactKeys(event.data, ['message', 'nonce']) && sent === nonce) commit(); return; } - // A failure message also carries a `reason`, and the frame reports - // `bad_hash` before it has learned a nonce to echo back. - if (message === RENDERER_FAILED_MESSAGE && (sent === nonce || sent === undefined)) 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); @@ -785,9 +796,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; @@ -796,8 +807,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"); @@ -805,11 +830,11 @@ var b=new Uint8Array(16);c.getRandomValues(b);var s="";for(var i=0;i { }); describe('Universal Creative APS failure relay', () => { - it('relays the frame failure reason to the top window for diagnostics', async () => { - const dynamicWindow = window as unknown as { - render?: (data: Record, helper: unknown, target: Window) => Promise; + /** + * 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 }), '*'), }; - const relayed: unknown[] = []; + } + + function captureRelays(): { + relayed: Array>; + stop: () => void; + } { + const relayed: Array> = []; const capture = (event: MessageEvent): void => { - const data = event.data as { message?: unknown } | undefined; - if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + 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!( - { adId: 'example-ad-id', apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, - undefined, + { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + pucHelper('example-ad-id'), window ); const iframe = document.body.querySelector('iframe')!; @@ -1031,7 +1054,7 @@ describe('Universal Creative APS failure relay', () => { }, ]); } finally { - window.removeEventListener('message', capture); + stop(); delete dynamicWindow.render; document.body.innerHTML = ''; } @@ -1041,19 +1064,14 @@ describe('Universal Creative APS failure relay', () => { const dynamicWindow = window as unknown as { render?: (data: Record, helper: unknown, target: Window) => Promise; }; - const relayed: unknown[] = []; - const capture = (event: MessageEvent): void => { - const data = event.data as { message?: unknown } | undefined; - if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); - }; - window.addEventListener('message', capture); + const { relayed, stop } = captureRelays(); vi.useFakeTimers(); window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); try { const rendered = dynamicWindow.render!( - { adId: 'timeout-ad-id', apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, - undefined, + { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + pucHelper('timeout-ad-id'), window ); rendered.catch(() => {}); @@ -1067,7 +1085,7 @@ describe('Universal Creative APS failure relay', () => { ]); } finally { vi.useRealTimers(); - window.removeEventListener('message', capture); + stop(); delete dynamicWindow.render; document.body.innerHTML = ''; } @@ -1077,17 +1095,40 @@ describe('Universal Creative APS failure relay', () => { const dynamicWindow = window as unknown as { render?: (data: Record, helper: unknown, target: Window) => Promise; }; - const relayed: Array<{ reason?: unknown }> = []; - const capture = (event: MessageEvent): void => { - const data = event.data as { message?: unknown; reason?: unknown } | undefined; - if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + 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; }; - window.addEventListener('message', capture); + const { relayed, stop } = captureRelays(); window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); try { const rendered = dynamicWindow.render!( - { adId: 'load-error-ad-id', apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, undefined, window ); @@ -1098,9 +1139,11 @@ describe('Universal Creative APS failure relay', () => { iframe.onerror?.(); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(relayed.map((entry) => entry.reason)).toEqual(['frame_load_error']); + // No helper means no relay, and the render still rejects rather than hanging. + expect(relayed).toEqual([]); + await expect(rendered).rejects.toThrow(); } finally { - window.removeEventListener('message', capture); + 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 113386970a..8d9181a300 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 @@ -3688,7 +3688,10 @@ describe('installTsRenderBridge', () => { getSlotElementId: () => 'div-header', getAdUnitPath: () => '/example/div-header', }; - store.recordTrustedServerOpportunity(slot, 'div-header', 'renderable_candidate'); + // 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. @@ -3722,6 +3725,59 @@ describe('installTsRenderBridge', () => { } }); + 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'; diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index af6f2e3248..6cc213d31f 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 render failed with no reason available. | + ### Source-neutral GAM facts `slotRenderEnded` may expose line item, order, advertiser, creative, yield-group, and From 64a05458a99837a83af7e1f8da97bdd33922a8ee Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:23:54 -0700 Subject: [PATCH 5/6] Resolve a relayed APS failure through the authenticated frame An ad unit code can be a configured prefix rather than a DOM id, and source authentication supports that: a capability registered for `div-` is legitimately served to the iframe under `div-header`. The relay then looked the code up in `divToSlotId`, which only ever holds resolved ids, so the lookup missed and every relayed failure for that supported configuration was dropped after authentication had already succeeded. Resolve the slot through the source resolver instead, which handles exact ids, configured prefixes, and ambiguity. The capability's own source check still runs first, so a sender outside the served ad unit is rejected before any slot is resolved. Reproduced by changing only the existing relay test's ad unit code from `div-header` to `div-`: the capability is served and the expected failure becomes undefined. That case is now its own regression test beside the exact-id one. Also assert the relay end to end in the browser, driving real Universal Creative with a renderer route that never acknowledges, so the reason arrives as `aps_frame_timeout` through a prefix-configured slot. Only a real browser shows which realm a relay comes from and which slot the authenticated frame resolves to, which is the class of gap that the unit tests could not see. --- .../browser/tests/shared/aps-renderer.spec.ts | 124 ++++++++++++++++++ .../lib/src/integrations/gpt/index.ts | 14 +- .../lib/test/integrations/gpt/ad_init.test.ts | 57 ++++++++ 3 files changed, 190 insertions(+), 5 deletions(-) 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 2f95697e2d..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 @@ -390,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, }) => { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index f64a0e223c..a4a7039488 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -2011,12 +2011,16 @@ export function installTsRenderBridge(): void { const tombstone = consumedPrebidApsIds.get(failedAdId); let failedSlotId: string | undefined; if (tombstone) { - // The Prebid path tombstones the publisher's ad unit code, which is a - // div id. The store keys associations by the configured Trusted Server - // slot id, so resolve it; a div Trusted Server never associated has no - // slot id and is left alone rather than recorded under the wrong key. + // 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 = window.tsjs?.divToSlotId?.[tombstone.adUnitCode]; + failedSlotId = slotFrameForMessageSource(e.source)?.slotId; } } else { // Server-side APS winners are served the same renderer and emit the 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 8d9181a300..cb245df33a 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 @@ -3725,6 +3725,63 @@ describe('installTsRenderBridge', () => { } }); + 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 }); From fcb6600ce17ad77ff2b2ab874a3d52787e8b5543 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:32:35 -0700 Subject: [PATCH 6/6] Hold an APS tombstone past the frame's timeout relay The previous tombstone minimum was exactly one frame timeout measured from consumption, but the frame starts its own timer only after the capability round trip, so its timeout relay always lands slightly later than that. A capability consumed with little TTL left therefore still lost its tombstone before the relay arrived, and the reason was dropped. The margin now covers two timeout windows. Bound it by the renderer frame's own timeout rather than the native-mode constant. Both are ten seconds today, but the tombstone has to outlive the frame's timer specifically, and the two could drift apart. A relay arriving just after consumption plus one timeout is now a regression test; it fails against the single-window margin. Also correct two claims that overstated what the code does. The Rust test's doc comment named JavaScript coverage for every reason, when the browser spec executes only bad_hash, nonce_mismatch, and descriptor_keys. The aps_unknown docs row described a render with no reason available, when the category is reached only by an empty reason string: 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. --- .../src/integrations/aps.rs | 12 ++-- .../lib/src/integrations/aps/render.ts | 9 ++- .../lib/src/integrations/gpt/index.ts | 13 ++-- .../lib/test/integrations/gpt/ad_init.test.ts | 64 +++++++++++++++++++ docs/guide/integrations/gpt-diagnostics.md | 24 +++---- 5 files changed, 99 insertions(+), 23 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 11f0d888cb..2c8cacd32d 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -3324,11 +3324,13 @@ mod tests { /// Guards the properties of the failure report that a substring check can /// actually establish. /// - /// Reachability of each reason is covered on the JavaScript side, where the - /// document is evaluated rather than read as text. 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. + /// 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. 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 bef091ea6b..6cecf7a1f8 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -37,7 +37,14 @@ 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 diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index a4a7039488..d2a89cdea3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -17,10 +17,10 @@ import type { TsjsApi, } from '../../core/types'; import { - APS_NATIVE_RENDERER_TIMEOUT_MS, APS_RENDER_FAILED_MESSAGE, APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + RENDERER_READY_TIMEOUT_MS, apsRenderFailureReason, apsRendererUrl, dispatchApsRendering, @@ -1945,10 +1945,13 @@ function recordConsumedPrebidApsId( adUnitCode: string ): void { // A capability consumed with little TTL left would otherwise lose its - // tombstone before the renderer frame's timeout fires, dropping the relayed - // reason. Holding it for at least that long also keeps the ad ID blocked - // longer, which is the safer direction for a security tombstone. - const minimum = Date.now() + APS_NATIVE_RENDERER_TIMEOUT_MS; + // 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 }); } 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 cb245df33a..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 @@ -15,6 +15,7 @@ import { 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'; @@ -3725,6 +3726,69 @@ describe('installTsRenderBridge', () => { } }); + 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'; diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 6cc213d31f..7e0e104c10 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -247,18 +247,18 @@ reason is validated against a fixed allowlist, and the sender is authenticated a 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 render failed with no reason available. | +| 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