-
Notifications
You must be signed in to change notification settings - Fork 13
Name the guard that stops an APS creative render #1052
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dc34278
fae9e35
4d3e02d
6b36f92
0c390c3
285e003
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,42 +78,54 @@ const APS_RENDERER_DOCUMENT: &str = r#"<!doctype html> | |
| var match=/^#tsaps=([A-Za-z0-9_-]{22,128})$/.exec(location.hash); | ||
| var expected=match&&match[1]; | ||
| try{history.replaceState(null,'',location.pathname+location.search);}catch(_error){} | ||
| if(!expected)return; | ||
| var reported=false; | ||
| function report(reason,nonce){ | ||
| if(reported)return; | ||
| reported=true; | ||
| try{parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:nonce,reason:reason},'*');}catch(_error){} | ||
| } | ||
| if(!expected){report('bad_hash');return;} | ||
| function keys(value,expectedKeys){ | ||
| if(!value||typeof value!=='object'||Array.isArray(value))return false; | ||
| var actual=Object.keys(value).sort(); | ||
| return actual.length===expectedKeys.length&&actual.every(function(key,index){return key===expectedKeys[index];}); | ||
| } | ||
| function validRenderer(renderer){ | ||
| function rendererProblem(renderer){ | ||
| if(!keys(renderer,['aaxResponse','accountId','bidId','creativeId','creativeUrl','height','tagType','type','version','width'])&& | ||
| !keys(renderer,['aaxResponse','accountId','bidId','creativeUrl','height','tagType','type','version','width']))return false; | ||
| if(renderer.type!=='aps'||renderer.version!==1||typeof renderer.accountId!=='string'||!renderer.accountId||new TextEncoder().encode(renderer.accountId).length>1024)return false; | ||
| if(typeof renderer.bidId!=='string'||!renderer.bidId||!Number.isInteger(renderer.width)||renderer.width<=0||!Number.isInteger(renderer.height)||renderer.height<=0)return false; | ||
| if(Object.prototype.hasOwnProperty.call(renderer,'creativeId')&&(typeof renderer.creativeId!=='string'||!renderer.creativeId||new TextEncoder().encode(renderer.creativeId).length>1024))return false; | ||
| if(renderer.tagType!=='iframe'&&renderer.tagType!=='script')return false; | ||
| if(typeof renderer.creativeUrl!=='string'||new TextEncoder().encode(renderer.creativeUrl).length>4096)return false; | ||
| if(typeof renderer.aaxResponse!=='string'||!renderer.aaxResponse||renderer.aaxResponse.length>349528)return false; | ||
| !keys(renderer,['aaxResponse','accountId','bidId','creativeUrl','height','tagType','type','version','width']))return 'descriptor_keys'; | ||
| if(renderer.type!=='aps'||renderer.version!==1||typeof renderer.accountId!=='string'||!renderer.accountId||new TextEncoder().encode(renderer.accountId).length>1024)return 'descriptor_fields'; | ||
| if(typeof renderer.bidId!=='string'||!renderer.bidId||!Number.isInteger(renderer.width)||renderer.width<=0||!Number.isInteger(renderer.height)||renderer.height<=0)return 'descriptor_fields'; | ||
| if(Object.prototype.hasOwnProperty.call(renderer,'creativeId')&&(typeof renderer.creativeId!=='string'||!renderer.creativeId||new TextEncoder().encode(renderer.creativeId).length>1024))return 'descriptor_fields'; | ||
| if(renderer.tagType!=='iframe'&&renderer.tagType!=='script')return 'descriptor_fields'; | ||
| if(typeof renderer.creativeUrl!=='string'||new TextEncoder().encode(renderer.creativeUrl).length>4096)return 'descriptor_fields'; | ||
| if(typeof renderer.aaxResponse!=='string'||!renderer.aaxResponse||renderer.aaxResponse.length>349528)return 'descriptor_fields'; | ||
| try{ | ||
| var url=new URL(renderer.creativeUrl); | ||
| if(url.protocol!=='https:'||url.username||url.password)return false; | ||
| if(url.protocol!=='https:'||url.username||url.password)return 'descriptor_envelope'; | ||
| var binary=atob(renderer.aaxResponse); | ||
| if(binary.length>262144||btoa(binary)!==renderer.aaxResponse)return false; | ||
| if(binary.length>262144||btoa(binary)!==renderer.aaxResponse)return 'descriptor_envelope'; | ||
| var bytes=Uint8Array.from(binary,function(character){return character.charCodeAt(0);}); | ||
| var decoded=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)); | ||
| if(!keys(decoded,['seatbid'])||!Array.isArray(decoded.seatbid)||decoded.seatbid.length!==1)return false; | ||
| if(!keys(decoded,['seatbid'])||!Array.isArray(decoded.seatbid)||decoded.seatbid.length!==1)return 'descriptor_envelope'; | ||
| var seat=decoded.seatbid[0]; | ||
| if(!keys(seat,['bid'])||!Array.isArray(seat.bid)||seat.bid.length!==1)return false; | ||
| if(!keys(seat,['bid'])||!Array.isArray(seat.bid)||seat.bid.length!==1)return 'descriptor_envelope'; | ||
| var bid=seat.bid[0]; | ||
| if(!keys(bid,['ext','h','id','price','w'])||!keys(bid.ext,['creativeurl','tagtype']))return false; | ||
| return bid.id===renderer.bidId&&bid.w===renderer.width&&bid.h===renderer.height&& | ||
| if(!keys(bid,['ext','h','id','price','w'])||!keys(bid.ext,['creativeurl','tagtype']))return 'descriptor_envelope'; | ||
| if(bid.id===renderer.bidId&&bid.w===renderer.width&&bid.h===renderer.height&& | ||
| bid.ext.creativeurl===renderer.creativeUrl&&bid.ext.tagtype===renderer.tagType&& | ||
| typeof bid.price==='number'&&Number.isFinite(bid.price)&&bid.price>=0; | ||
| }catch(_error){return false;} | ||
| typeof bid.price==='number'&&Number.isFinite(bid.price)&&bid.price>=0)return undefined; | ||
| return 'descriptor_envelope'; | ||
| }catch(_error){return 'descriptor_envelope';} | ||
| } | ||
| function receive(event){ | ||
| if(event.source!==parent)return; | ||
| var message=event.data; | ||
| if(!keys(message,['nonce','renderer'])||message.nonce!==expected||!validRenderer(message.renderer))return; | ||
| // Stay silent for traffic that is not shaped like the render handshake, so an | ||
| // unrelated sender cannot consume this frame's single report. | ||
| if(!keys(message,['nonce','renderer']))return; | ||
| if(event.source!==parent){report('source_mismatch');return;} | ||
|
jevansnyc marked this conversation as resolved.
jevansnyc marked this conversation as resolved.
jevansnyc marked this conversation as resolved.
|
||
| if(message.nonce!==expected){report('nonce_mismatch');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,41 @@ mod tests { | |
| assert!(APS_RENDERER_CSP.contains("sandbox allow-forms")); | ||
| assert!(!APS_RENDERER_CSP.contains("allow-same-origin")); | ||
| } | ||
|
|
||
| #[test] | ||
|
jevansnyc marked this conversation as resolved.
|
||
| fn renderer_document_reports_a_reason_for_every_silent_guard() { | ||
| for reason in [ | ||
| "bad_hash", | ||
| "source_mismatch", | ||
| "nonce_mismatch", | ||
| "descriptor_keys", | ||
| "descriptor_fields", | ||
| "descriptor_envelope", | ||
| "amazon_script_error", | ||
| ] { | ||
| assert!( | ||
| APS_RENDERER_DOCUMENT.contains(reason), | ||
| "renderer document should report a `{reason}` reason instead of returning silently" | ||
| ); | ||
| } | ||
|
|
||
| // Reasons travel on the existing failure message rather than a new channel. | ||
| assert!( | ||
| APS_RENDERER_DOCUMENT.contains("reason:reason"), | ||
| "should attach the reason to the failure message" | ||
| ); | ||
|
|
||
| // A reason is a fixed category, never a copy of the rejected descriptor. | ||
| assert!(!APS_RENDERER_DOCUMENT.contains("JSON.stringify(renderer)")); | ||
| assert!(!APS_RENDERER_DOCUMENT.contains("reason:message")); | ||
|
|
||
| // Reporting is one-shot so a hostile sender cannot flood the parent. | ||
| assert!( | ||
| APS_RENDERER_DOCUMENT.contains("if(reported)return"), | ||
| "should report at most one reason per frame" | ||
| ); | ||
|
|
||
| // A foreign sender is answered through the parent, never the sender. | ||
|
Comment on lines
+3325
to
+3357
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤔 thinking — This test asserts literal presence in a string, not guard behaviour. It is a substring test over a
The three negative assertions are a different matter and genuinely valuable — For the positive half, the JS suite already evaluates a sibling document in jsdom ( I confirmed nothing regressed here: |
||
| assert!(!APS_RENDERER_DOCUMENT.contains("event.source.postMessage")); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -181,12 +181,37 @@ export type GptDiagnosticsTrustedServerOpportunity = | |
| | 'unrenderable_candidate' | ||
| | 'no_candidate'; | ||
|
|
||
| /** A safe failure category observed while obtaining or posting creative markup. */ | ||
| /** | ||
| * A safe failure category observed while obtaining or posting creative markup. | ||
| * | ||
| * The `aps_` members cover the APS Universal Creative 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. Each | ||
| * member names the exact guard that stopped the render. | ||
| */ | ||
| export type GptDiagnosticsCreativeFailure = | ||
| | 'missing_render_source' | ||
| | 'cache_fetch_failed' | ||
| | 'invalid_cache_payload' | ||
| | 'response_post_failed'; | ||
| | 'response_post_failed' | ||
| // Reported by the sandboxed renderer document and relayed by the creative. | ||
|
jevansnyc marked this conversation as resolved.
|
||
| | 'aps_bad_hash' | ||
|
jevansnyc marked this conversation as resolved.
jevansnyc marked this conversation as resolved.
|
||
| | 'aps_nonce_mismatch' | ||
| | 'aps_source_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' | ||
| // Observed on the Trusted Server side of the capability handshake. | ||
| | 'aps_consumed_tombstone' | ||
| | 'aps_source_not_in_ad_unit' | ||
| | 'aps_missing_renderer_url' | ||
| | 'aps_tombstone_capacity'; | ||
|
jevansnyc marked this conversation as resolved.
Comment on lines
+197
to
+214
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔧 wrench — The store's runtime allowlist rejects all fifteen new reasons, so nothing this PR adds is ever recorded. This union is the compile-time contract. The runtime contract is function isCreativeFailure(reason: unknown): reason is GptDiagnosticsCreativeFailure {
return (
reason === 'missing_render_source' ||
reason === 'cache_fetch_failed' ||
reason === 'invalid_cache_payload' ||
reason === 'response_post_failed'
);
}
I verified this against the real Exhaustively over the union, the four originals are accepted and all fifteen Apply manually — the fix lives in const CREATIVE_FAILURES = [
'missing_render_source',
'cache_fetch_failed',
'invalid_cache_payload',
'response_post_failed',
'aps_bad_hash',
// ... the remaining aps_* members
] as const;
export type GptDiagnosticsCreativeFailure = (typeof CREATIVE_FAILURES)[number];
const CREATIVE_FAILURE_SET: ReadonlySet<string> = new Set(CREATIVE_FAILURES);
function isCreativeFailure(reason: unknown): reason is GptDiagnosticsCreativeFailure {
return typeof reason === 'string' && CREATIVE_FAILURE_SET.has(reason);
}
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔧 wrench — Widening this union makes
facts.push(creativeFailureFact(failure));Confirmed with Apply manually — the fix is in |
||
|
|
||
| /** Delivery evidence derived for a GPT request cycle. */ | ||
| export type GptDiagnosticsDelivery = | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; | ||
|
|
@@ -32,6 +37,58 @@ const activeFrames = new WeakMap<HTMLElement, HTMLIFrameElement>(); | |
| const pendingFrameCancels = new WeakMap<HTMLElement, () => void>(); | ||
| const RENDERER_READY_MESSAGE = 'trusted-server/aps/renderer-ready'; | ||
| const RENDERER_FAILED_MESSAGE = 'trusted-server/aps/renderer-failed'; | ||
| /** | ||
| * 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 validates the reason against | ||
| * [`APS_RENDER_FAILURE_REASONS`] 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. | ||
| */ | ||
| const APS_RENDER_FAILURE_REASONS: Readonly<Record<string, GptDiagnosticsCreativeFailure>> = | ||
|
jevansnyc marked this conversation as resolved.
|
||
| Object.freeze( | ||
| Object.assign( | ||
| Object.create(null) as Record<string, GptDiagnosticsCreativeFailure>, | ||
| { | ||
| bad_hash: 'aps_bad_hash', | ||
| nonce_mismatch: 'aps_nonce_mismatch', | ||
| source_mismatch: 'aps_source_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 | ||
| ) | ||
| ); | ||
|
|
||
| /** | ||
| * 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' ? APS_RENDER_FAILURE_REASONS[value] : undefined; | ||
| } | ||
| const RENDERER_READY_TIMEOUT_MS = 10_000; | ||
| const MAX_PREBID_RENDERER_ENTRIES = 256; | ||
| const DEFAULT_PREBID_RENDERER_TTL_SECONDS = 300; | ||
|
|
@@ -711,12 +768,13 @@ var b=new Uint8Array(16);c.getRandomValues(b);var s="";for(var i=0;i<b.length;i+ | |
| var n=w.btoa(s).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/g,""); | ||
| var f=w.document.createElement("iframe"),done=false,t; | ||
| function clean(){w.removeEventListener("message",receive);if(t)w.clearTimeout(t);} | ||
| function fail(){if(done)return;done=true;clean();f.remove();reject(new Error("APS renderer frame failed"));} | ||
| function receive(e){var m=e.data;if(e.source!==f.contentWindow||!m||m.nonce!==n)return; | ||
| if(m.message==="${RENDERER_READY_MESSAGE}"){done=true;clean();resolve();} | ||
| else if(m.message==="${RENDERER_FAILED_MESSAGE}")fail();} | ||
| function report(x){try{(w.top||w).postMessage({message:"${APS_RENDER_FAILED_MESSAGE}",adId:(d&&typeof d.adId==="string")?d.adId:"",reason:x},"*");}catch(_e){}} | ||
|
jevansnyc marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔧 wrench — The renderer document's new three-key failure message is silently ignored by the direct render path. This is a behavioural regression, not a diagnostics gap.
parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:nonce,reason:reason},'*');There are two consumers of that message. The Universal Creative source right here was updated to a permissive per-field match. The direct path was not — if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) {
return;
}
Reproduced against the real This path is live: Apply manually — the fix is at function receive(event: MessageEvent): void {
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;
}
// The renderer document attaches a `reason` to its failure message, and
// reports `bad_hash` before it can echo a nonce.
if (message === RENDERER_FAILED_MESSAGE && (sent === nonce || sent === undefined)) fail();
}Scratch-verified in an isolated worktree: teardown on a three-key failure, teardown on nonce-less |
||
| function fail(x){if(done)return;done=true;clean();f.remove();report(x||"unknown");reject(new Error("APS renderer frame failed"));} | ||
| function receive(e){var m=e.data;if(e.source!==f.contentWindow||!m)return; | ||
| if(m.message==="${RENDERER_READY_MESSAGE}"&&m.nonce===n){done=true;clean();resolve();} | ||
| else if(m.message==="${RENDERER_FAILED_MESSAGE}"&&(m.nonce===n||m.nonce===undefined))fail(typeof m.reason==="string"?m.reason:"frame_reported_failure");} | ||
|
jevansnyc marked this conversation as resolved.
|
||
| f.width=String(r.width);f.height=String(r.height);f.style.border="0"; | ||
| f.setAttribute("sandbox","${APS_RENDERER_SANDBOX}"); | ||
| f.src=p.href+"#tsaps="+n;f.onload=function(){if(!done&&f.contentWindow)f.contentWindow.postMessage({nonce:n,renderer:r},"*");}; | ||
| f.onerror=fail;w.addEventListener("message",receive);t=w.setTimeout(fail,${RENDERER_READY_TIMEOUT_MS});w.document.body.appendChild(f); | ||
| f.onerror=function(){fail("frame_load_error");};w.addEventListener("message",receive);t=w.setTimeout(function(){fail("frame_timeout");},${RENDERER_READY_TIMEOUT_MS});w.document.body.appendChild(f); | ||
| }catch(e){reject(e);}});};})();`; | ||
Uh oh!
There was an error while loading. Please reload this page.