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
98 changes: 79 additions & 19 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){}
}
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',expected);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,52 @@ 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.
///
/// 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.
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"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -369,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: '<!doctype html><div id="div-aps"></div>',
}),
);
await page.route(outerCreativeUrl, (route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: `<!doctype html><script>${PUC_BANNER}</script><script>
window.ucTag.renderAd(document, { adId: ${JSON.stringify(adId)}, pubUrl: ${JSON.stringify(publisherOrigin)} });
</script>`,
}),
);
// 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: "<!doctype html><title>silent renderer</title>",
}),
);

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<string, unknown>;
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,
}) => {
Expand Down Expand Up @@ -566,7 +711,7 @@ parent.postMessage(JSON.stringify({
expect.objectContaining({
message: "Prebid Response",
adId: result.acceptedAdId,
rendererVersion: 4,
rendererVersion: universalCreativeRendererVersion(),
apsRenderer,
}),
);
Expand Down Expand Up @@ -903,6 +1048,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<string, unknown>;
}>;
}
).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 }) => {
Expand Down
Loading
Loading