Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 69 additions & 20 deletions crates/trusted-server-core/src/integrations/aps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,42 +56,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;}
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='';
Expand All @@ -106,7 +118,7 @@ function receive(event){
var script=document.createElement('script');
script.src='https://client.aps.amazon-adsystem.com/prebid-creative.js';
script.onload=function(){parent.postMessage({message:'trusted-server/aps/renderer-ready',nonce:acceptedNonce},'*');};
script.onerror=function(){parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:acceptedNonce},'*');};
script.onerror=function(){report('amazon_script_error',acceptedNonce);};
document.head.appendChild(script);
}
addEventListener('message',receive);
Expand Down Expand Up @@ -2553,4 +2565,41 @@ mod tests {
assert!(APS_RENDERER_CSP.contains("sandbox allow-forms"));
assert!(!APS_RENDERER_CSP.contains("allow-same-origin"));
}

#[test]
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"));
}
}
29 changes: 27 additions & 2 deletions crates/trusted-server-js/lib/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,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.
| 'aps_bad_hash'
| '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';

/** Delivery evidence derived for a GPT request cycle. */
export type GptDiagnosticsDelivery =
Expand Down
64 changes: 59 additions & 5 deletions crates/trusted-server-js/lib/src/integrations/aps/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ApsNativeRendererHook,
ApsPrebidRendererEntry,
ApsRendererV1,
GptDiagnosticsCreativeFailure,
TsjsApi,
} from '../../core/types';

Expand Down Expand Up @@ -34,6 +35,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>> =
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;
Expand Down Expand Up @@ -563,12 +616,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){}}
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");}
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);}});};})();`;
Loading
Loading