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 @@ -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){}
Comment thread
jevansnyc marked this conversation as resolved.
}
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;}
Comment thread
jevansnyc marked this conversation as resolved.
Comment thread
jevansnyc marked this conversation as resolved.
Comment thread
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='';
Expand All @@ -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);
Expand Down Expand Up @@ -3308,4 +3320,41 @@ mod tests {
assert!(APS_RENDERER_CSP.contains("sandbox allow-forms"));
assert!(!APS_RENDERER_CSP.contains("allow-same-origin"));
}

#[test]
Comment thread
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 &str constant and executes no JavaScript, so it cannot show that any guard actually reports. I checked how weak that is concretely: a stub document that defines report() but never calls it, with the seven reason strings parked in a comment, passes every positive assertion here. A reason literal could be deleted from its return statement and left in a comment and the test stays green.

contains("reason:reason") proves the object literal is spelled that way, not that report is ever invoked. contains("if(reported)return") proves the early return is written, not that reported is ever set or that the guard precedes the post.

The three negative assertions are a different matter and genuinely valuable — !contains("JSON.stringify(renderer)"), !contains("reason:message"), and !contains("event.source.postMessage") are real tripwires against a future edit that would leak descriptor data or answer the sender. Those I would keep as-is.

For the positive half, the JS suite already evaluates a sibling document in jsdom (render.test.ts:838 does window.eval(APS_UNIVERSAL_CREATIVE_RENDERER)), so the same technique is available for APS_RENDERER_DOCUMENT and would let these assertions test the guards rather than the source text. That is also the coverage that would have caught the direct-path regression flagged on render.ts.

I confirmed nothing regressed here: cargo test -p trusted-server-core --target aarch64-apple-darwin aps gives 116 passed; 0 failed.

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 @@ -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.
Comment thread
jevansnyc marked this conversation as resolved.
| 'aps_bad_hash'
Comment thread
jevansnyc marked this conversation as resolved.
Comment thread
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';
Comment thread
jevansnyc marked this conversation as resolved.
Comment on lines +197 to +214

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 isCreativeFailure in crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts:168-175, which this PR does not touch and which still lists only the original four:

function isCreativeFailure(reason: unknown): reason is GptDiagnosticsCreativeFailure {
  return (
    reason === 'missing_render_source' ||
    reason === 'cache_fetch_failed' ||
    reason === 'invalid_cache_payload' ||
    reason === 'response_post_failed'
  );
}

recordTrustedServerCreativeFailure bails on it at store.ts:540 before recording anything — no failure, and not even an attribution issue to signal the drop.

I verified this against the real GptDiagnosticsStore on a live attempt (no response recorded, well inside the attempt window), so nothing else is masking it:

recordTrustedServerCreativeFailure(attemptId, 'aps_frame_timeout')   -> snapshot has no 'aps_frame_timeout'
recordTrustedServerCreativeFailure(attemptId, 'response_post_failed') -> snapshot has 'response_post_failed'

Exhaustively over the union, the four originals are accepted and all fifteen aps_* members are dropped. That makes every new emission site in this PR a no-op: the bridge guards (aps_consumed_tombstone, aps_source_not_in_ad_unit, aps_descriptor_fields, aps_tombstone_capacity, aps_missing_renderer_url) and the entire relay branch. The only reason that still lands is response_post_failed, which already worked before this PR.

Apply manually — the fix lives in store.ts, which this PR does not modify. Rather than hand-extending the validator, derive both from one source so they cannot drift again:

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);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Widening this union makes creativeFailureFact non-exhaustive, so ts_console renders undefined.

crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts:175-188 switches over this union with no default, declared : string. It is exhaustive on main; this PR adds fifteen members that fall through and return undefined, which overlay.ts:262 pushes straight into the facts list:

facts.push(creativeFailureFact(failure));

Confirmed with tsc --noEmit: overlay.ts(177,4): error TS2366: Function lacks ending return statement and return type does not include 'undefined'. I checked the merge base (066ea3c69) and this error is not present there, so it is introduced by this branch. It does not fail CI because no check runs tsc --noEmit — vitest typechecking covers test files only.

Apply manually — the fix is in overlay.ts, outside this PR's diff. Add the aps_* cases with operator-readable text. Keeping the switch exhaustive with no default is the right call: it is what would have caught this at compile time.


/** Delivery evidence derived for a GPT request cycle. */
export type GptDiagnosticsDelivery =
Expand Down
70 changes: 64 additions & 6 deletions crates/trusted-server-js/lib/src/integrations/aps/render.ts
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';
Expand Down Expand Up @@ -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>> =
Comment thread
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;
Expand Down Expand Up @@ -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){}}
Comment thread
jevansnyc marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.

report() in aps.rs now always posts three keys:

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 — render.ts:724-731 still gates on an exact two-key match:

if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) {
  return;
}

hasExactKeys compares key counts (render.ts:194-205), so the added reason key fails the match and every renderer-failed message is dropped. fail() never runs.

Reproduced against the real renderApsCreative with a scratch vitest:

{message, nonce}                  -> iframe torn down       (passes, pre-PR behaviour)
{message, nonce, reason}          -> iframe still connected (fails)

This path is live: core/request.ts:59 wires renderApsCreative as the trustedServer renderer. The consequence is that a rejected descriptor or an Amazon script error no longer fails fast — the hidden frame stays mounted over publisher content for the full 10s RENDERER_READY_TIMEOUT_MS, and the log.warn('APS renderer: frame load failed') diagnostic is delayed by the same amount.

Apply manually — the fix is at render.ts:724-731, outside this PR's hunks. This version also handles bad_hash, where the frame never learned a nonce to echo:

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 bad_hash, still ignores a foreign nonce, still rejects a ready message with an extra key. 235 tests pass and prettier is clean. Please add a test asserting the three-key failure tears the frame down — nothing currently covers it.

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");}
Comment thread
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);}});};})();`;
Loading
Loading