-
Notifications
You must be signed in to change notification settings - Fork 12
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
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 |
|---|---|---|
|
|
@@ -57,42 +57,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;} | ||
|
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. 🔧 P1: Do not let a foreign sender terminate rendering A shaped message from a non-parent source now emits Keep
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. 🤔 Moving the source check below the shape check lets a foreign sender consume the one-shot report. Previously That is reachable in practice: indexed access on a cross-origin The description says "an unrelated sender cannot consume the report or learn from it." The second half holds — answering through
Proposed — restore the ordering and drop the reason: function receive(event){
if(event.source!==parent)return;
var message=event.data;
// 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(message.nonce!==expected){report('nonce_mismatch');return;}If you'd rather keep the reason, the alternative is to report it without setting Not offered as a one-click suggestion because either shape needs matching updates:
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 - Moving the source check below the shape check lets a co-resident script cancel a render that would have succeeded. Two things changed together here: The comment just above says the shape check means "an unrelated sender cannot consume this frame's single report." That holds only against unshaped noise. An adversary who has read these two lines sends The more serious half is on the consumer side. The Universal Creative handler treats a On reachability: the frame is never named and cross-origin siblings cannot enumerate it, so this is not reachable from an arbitrary third party. It is reachable from anything running in the same document as the frame's parent, which on the Universal Creative path is Amazon/GAM-served creative code. That is a real population for an ad-serving surface, even if it is not the open internet. Proposed fix (apply manually - restoring the guard's position and dropping the report is a two-part edit across the reordered block): 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');return;}
var problem=rendererProblem(message.renderer);
if(problem){report(problem,message.nonce);return;}A message from a non-parent source is by definition not attributable to the real render, so reporting it buys no diagnostic value and costs the one-shot budget. That also lets |
||
| 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=''; | ||
|
|
@@ -107,7 +119,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); | ||
|
|
@@ -2613,4 +2625,41 @@ mod tests { | |
| assert!(APS_RENDERER_CSP.contains("sandbox allow-forms")); | ||
| assert!(!APS_RENDERER_CSP.contains("allow-same-origin")); | ||
| } | ||
|
|
||
| #[test] | ||
|
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. nitpick - This test is close to a tautology and would not have caught either regression in this PR. It searches a Concretely: Two assertions are also coupled to minified spelling rather than behaviour: The negative assertions ( None of this is a blocker, and I recognise the constraint: the renderer is a JS program embedded in a Rust string with no JS-side harness, so substring matching is nearly the only tool available in
|
||
| 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. | ||
| 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. | ||
|
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 rejects all 15 new This union is the compile-time contract, but 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 confirmed this against a real So every reason this PR introduces is silently discarded: the relayed frame reasons and the five handshake reasons ( The reason the suite does not catch this is that Proposed fix (apply manually - const CREATIVE_FAILURES: ReadonlySet<GptDiagnosticsCreativeFailure> = new Set([
'missing_render_source',
'cache_fetch_failed',
'invalid_cache_payload',
'response_post_failed',
'aps_bad_hash',
'aps_nonce_mismatch',
'aps_source_mismatch',
'aps_descriptor_keys',
'aps_descriptor_fields',
'aps_descriptor_envelope',
'aps_runner_script_error',
'aps_frame_timeout',
'aps_frame_load_error',
'aps_frame_reported_failure',
'aps_unknown',
'aps_consumed_tombstone',
'aps_source_not_in_ad_unit',
'aps_missing_renderer_url',
'aps_tombstone_capacity',
]);
function isCreativeFailure(reason: unknown): reason is GptDiagnosticsCreativeFailure {
return (
typeof reason === 'string' &&
CREATIVE_FAILURES.has(reason as GptDiagnosticsCreativeFailure)
);
}Worth pairing that with a type-level exhaustiveness assertion, so a future widening of this union cannot again leave the guard behind silently. Something like a |
||
| | 'aps_bad_hash' | ||
|
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. 🔧 P1: Wire the new APS reasons through the real diagnostics consumers The runtime validator in Update the store allowlist and overlay labels for every new category, make the presentation switch exhaustive, and add a test that passes an APS reason through the actual recorder/store and verifies the snapshot and overlay output.
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. 🔧 Every new The union grew fifteen members here, but the runtime allowlist it is validated against did not. function isCreativeFailure(reason: unknown): reason is GptDiagnosticsCreativeFailure {
return (
reason === 'missing_render_source' ||
reason === 'cache_fetch_failed' ||
reason === 'invalid_cache_payload' ||
reason === 'response_post_failed'
);
}
Verified against the real "trustedServerCreativeFailures": ["response_post_failed"]So Fix in Not offered as a suggestion: the fix is in a file outside this diff. |
||
| | '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'; | ||
|
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. 🔧
for (const failure of new Set(cycle.trustedServerCreativeFailures ?? [])) {
facts.push(creativeFailureFact(failure));
}This is latent today only because the store gap above blocks every TypeScript does flag it ( Fix: add a |
||
|
|
||
| /** 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>> = | ||
|
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. 👍 Right shape for a cross-origin relay, and the tests actually prove it. Null-prototype backing object plus |
||
| 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){}} | ||
|
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 3-key failure message is silently ignored by the direct render path, which 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 and only one was updated. The Universal Creative source on the line below matches on if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) {
return;
}
I confirmed it by driving So on this path an The new Rust test cannot catch this: it asserts on substrings of Proposed fix (apply manually - function receive(event: MessageEvent): void {
if (
event.source !== iframe.contentWindow ||
!(
hasExactKeys(event.data, ['message', 'nonce']) ||
hasExactKeys(event.data, ['message', 'nonce', 'reason'])
)
) {
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 regression test that posts the 3-key shape and asserts the frame is torn down would keep the two consumers honest with each other. |
||
| 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");} | ||
|
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 - Accepting
The reason is clear enough from the other side: The cost is that a nonce is no longer required to trigger Mostly worth a comment on this line explaining why the unnonced case is accepted, so the asymmetry between the two branches does not read as an oversight to the next reader. If the |
||
| 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);}});};})();`; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 This breaks the direct APS render path's failure teardown.
report()always posts three keys —message,nonce,reason— including whennonceisundefined. The direct (non-Prebid) render path still gates on an exact two-key match, atrender.ts:573, unchanged by this PR:hasExactKeyscompares the full sorted key set, so every failure message from this document is now silently discarded there andfail()never runs. The frame is no longer torn down on an explicit failure — it lingers for the fullRENDERER_READY_TIMEOUT_MS(10s) before the timeout restores publisher content.Verified by driving
renderApsCreativewith both message shapes:renderer-readystill posts exactly two keys, so success is unaffected — only the failure fast path is dead. CI stayed green because the nearest existing test (leaves existing slot content intact when validation or loading fails) exercises the iframeerrorevent, never the message, and the Rust test added here is string-matching onAPS_RENDERER_DOCUMENTso it structurally cannot see the mismatch.Fix at
render.ts:573, accepting both shapes:Worth a regression test on that path too, since nothing currently covers the message-driven teardown. Not offered as a suggestion: the fix is in a file outside this diff.