Skip to content

Render APS creatives in opaque data frames - #1033

Draft
ChristianPavilonis wants to merge 4 commits into
mainfrom
fix/aps-same-origin
Draft

Render APS creatives in opaque data frames#1033
ChristianPavilonis wants to merge 4 commits into
mainfrom
fix/aps-same-origin

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • Render APS creatives through nonce-bound, nested opaque data: documents so HTTPS creative origins are retained without granting publisher access.
  • Add exact-origin renderer containment, one-shot PUC top-page mounts, and APS-wide publisher frame-ancestors 'self' protection against recursive publisher framing.
  • Cover direct and real PUC rendering in Chromium, Firefox, and WebKit; document the CSP and redirect compatibility trade-offs.

Changes

File Change
crates/trusted-server-core/src/integrations/aps.rs Serve the data-bootstrap renderer mode and static renderer sources.
crates/trusted-server-core/src/response_privacy.rs Append an independent APS publisher frame-ancestors 'self' CSP policy.
crates/trusted-server-js/lib/src/integrations/aps/ Add the bootstrap/container/renderer documents and nonce- and port-bound data-frame rendering flow.
crates/trusted-server-js/lib/src/integrations/gpt/index.ts Register and consume one-shot PUC top-page mount capabilities.
crates/trusted-server-js/lib/src/core/request.ts Cancel pending APS work before generic slot replacement.
crates/trusted-server-js/lib/test/ Add renderer, capability refresh/replay, and replacement-cancellation coverage.
crates/trusted-server-integration-tests/browser/ Add real PUC fixture and cross-browser APS isolation coverage.
docs/guide/integrations/aps.md Document containment architecture, CSP requirements, and compatibility trade-offs.

Closes

Closes #999

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: cargo test-cloudflare, cargo test-spin, adapter parity tests, Cloudflare/Spin Clippy targets, git diff --check, Chromium browser integration tests (Next.js and WordPress), and Firefox/WebKit browser integration tests (Next.js).

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • No new logging added; existing code uses project-standard log macros rather than println!
  • New code has tests
  • No secrets or credentials committed

@aram356 aram356 added this to the 202608 milestone Aug 17, 2026
@aram356
aram356 self-requested a review August 17, 2026 15:35

@prk-Jr prk-Jr left a comment

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.

Summary

Nested opaque data: documents with per-layer nonces and a transferred MessagePort are a genuine improvement over the single sandboxed route: the envelope, bid ID, price, and creative URL now never appear in any URL, and the browser tests assert on real compromise markers rather than the absence of an error. The containment reasoning holds up under review.

Three things block merge: the top-page mount leaves the slot in a state no later GAM refresh can recover from, the legacy fallback posts a message shape its only possible consumer rejects, and the browser-specific behavior the whole design rests on is not exercised by CI.

Blocking

🔧 wrench

  • Universal-creative mount permanently forks the slotcommit() hides the GAM/PUC iframe forever and nothing removes the mounted APS frame on a later refresh, so the slot ends up either blank or double-rendered, and ActiveView measures a hidden iframe (crates/trusted-server-js/lib/src/integrations/aps/render.ts:416, inline).
  • Legacy fallback message shape can never be accepted — the three-key message is rejected by the only document that path can reach, so the documented rollback never renders; the phase is also entirely untested (crates/trusted-server-js/lib/src/integrations/aps/render.ts:428, inline).
  • Cross-browser containment is not gated by CIcrates/trusted-server-integration-tests/browser/playwright.config.ts:16-21 defines only a chromium project, and .github/workflows/integration-tests.yml:201 installs only chromium. The security model depends on browser-specific behavior this PR's own docs name explicitly: CSP inheritance into data: frames (the container frame-src confining the inner renderer) and WebKit opaque-origin propagation. The PR description reports Firefox and WebKit runs, but nothing in the repository executes them, so a regression in either behavior ships silently. Please add firefox and webkit projects — scoped to aps-renderer.spec.ts if full-suite runtime is a concern.

❓ question

  • No operator kill switch for the new rendering modemountApsRendererFrame always builds the bootstrap URL, so the mode is baked into the JS bundle. The docs warn that the exact-origin frame-src "intentionally blocks an immediate creative-frame redirect" and ask operators to "validate real APS inventory for intermediate or redirect origins before rollout". If redirecting creatives turn up in production, is a JS rebuild and redeploy the only remedy? A config flag selecting legacy vs. data-bootstrap would make the rollout reversible at config-push speed.

Non-blocking

🤔 thinking

  • frame-ancestors 'self' is site-wide and coupled to APS enablement (crates/trusted-server-core/src/response_privacy.rs:169, inline).
  • Two documents on one path with no cache directives (crates/trusted-server-core/src/integrations/aps.rs:1155, inline).

♻️ refactor

  • Derive APS enablement from the typed config rather than raw JSON (crates/trusted-server-core/src/response_privacy.rs:33, inline).

📌 out of scope

  • The ancestor-allowlist feature the docs promise before publishers with trusted external embedders can enable APS deserves a tracked follow-up issue.

⛏ nitpick

  • Rust test asserts on Prettier-formatted HTML (crates/trusted-server-core/src/integrations/aps.rs:2406, inline).

📝 note

  • Cross-crate include_str! into the JS crate's source tree (crates/trusted-server-core/src/integrations/aps.rs:54, inline).

👍 praise

  • Per-layer nonces plus a transferred MessagePort, so the envelope, bid ID, price, and creative URL never appear in any URL (render.ts:341-360, renderer-container.html:51-65).
  • The one-shot mount capability carries an honest doc comment about why source-window equality cannot survive the PUC handoff (render.ts:520, inline).
  • Number.isIntegerNumber.isSafeInteger in both the TypeScript and in-document validators.
  • Browser tests assert on an actual compromise marker (document.body.dataset.apsCompromised) instead of on the absence of an error.

CI Status

All 19 checks pass on 19c3709.

  • fmt: PASS
  • clippy / cargo check (fastly, axum, cloudflare native + wasm, spin native + wasm): PASS
  • rust tests (core, axum, cloudflare, spin, ts CLI, cross-adapter parity): PASS
  • js tests (vitest) and format-typescript: PASS
  • browser + integration tests: PASS (chromium only — see the blocking finding above)
  • format-docs: PASS

} else if (child instanceof HTMLElement) {
// Keep Universal Creative connected long enough to receive the success
// acknowledgement, but never leave two visible rendering surfaces.
child.style.display = 'none';

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 — Universal-creative mount permanently forks the slot.

commit() in mode: 'universal-creative' hides the GAM/PUC iframe and never restores or removes it. The mounted APS iframe is removed only when a newer APS mount commits (the dataset.tsApsRenderer branch above). Nothing hooks slot lifecycle — the slotRenderEnded listener in gpt/index.ts only runs the debug adm bypass.

Failure: the slot renders APS through PUC, then the publisher calls refresh() and GAM returns a non-TS creative for the same div.

  • If GPT reuses the iframe, the new creative paints inside a display: none iframe → blank slot on a billed impression.
  • If GPT creates a fresh iframe, the stale APS creative and the new GAM creative both sit in the div.

Before this PR the renderer lived inside the PUC frame, so a refresh tore it down together with the GAM document. Moving the mount to the top page drops that cleanup.

Secondary: ActiveView now measures a hidden GAM iframe while the creative paints in a sibling, so viewability on these impressions reports ~0.

Fix: track the mounted frame per div and, on slotRenderEnded for that div, remove it and unhide the GAM iframe unless the new render is the TS APS render.

fail();
return;
}
target.postMessage({ nonce, publisherOrigin, renderer }, '*');

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 legacy fallback message shape can never be accepted.

After LEGACY_RENDERER_FALLBACK_MS without bootstrap-ready, this posts { nonce, publisherOrigin, renderer }. The only document reachable at ?mode=data-bootstrap that lacks the new handler is the pre-PR APS_RENDERER_DOCUMENT — the old Rust handler ignored the query string entirely and served the renderer for any query. That document requires exactly:

keys(message, ['nonce', 'renderer'])

so the three-key message is dropped, renderer-ready never arrives, and the 10 s timeout removes the frame with no ad. The rollback path documented in docs/guide/integrations/aps.md does not work.

No test exercises phase === 'legacy' — there is no coverage in the unit specs or the browser spec.

Fix:

target.postMessage({ nonce, renderer }, '*');

The new renderer.html accepts both the legacy and modern shapes, so the two-key message stays compatible in both directions. Please add a test for the fallback phase.

If the intended consumer is instead a cache that collapses the query string and serves the new document, that is worth stating explicitly — but the pre-PR rollback case still needs handling either way.

// a publisher URL beneath the opaque renderer unless every publisher
// response rejects the cross-origin ancestor chain. Append this independent
// policy after operator headers so configuration cannot weaken it.
enforce_aps_publisher_frame_ancestors(settings, response);

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 appends frame-ancestors 'self' to every Trusted Server response whenever integrations.aps.enabled = true, which silently changes the publisher's site-wide embedding policy: AMP caches, partner embeds, in-app webviews, and same-brand cross-origin framing all stop working the moment APS is turned on.

The containment argument for it is sound and the docs are honest about the trade-off, so this is not a request to change the behavior — but it deserves an explicit release note, since the blast radius reaches every page rather than the ad slot.

📌 out of scope — the docs promise "a separately reviewed ancestor-allowlist feature before enabling APS". Worth a tracked follow-up issue so that promise does not go stale.

fn enforce_aps_publisher_frame_ancestors(settings: &Settings, response: &mut Response) {
let aps_enabled = settings
.integrations
.get(APS_INTEGRATION_ID)

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.

♻️ refactor — This re-declares APS_INTEGRATION_ID and re-derives enablement from raw JSON. Settings::integration_config::<ApsConfig> and IntegrationConfig::is_enabled already exist and are the source of truth used by registration.

Using them keeps the two definitions from drifting if APS enablement ever gains another condition — otherwise a future change to ApsConfig::is_enabled leaves this header check silently out of sync with whether APS is actually running.

message: "Failed to build APS not-found response".to_string(),
});
}
let (renderer_document, renderer_csp) = match request.uri().query() {

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 — Two different documents, with two different CSPs, are now served from one path and distinguished only by the query string, with no Cache-Control and no Vary on either response.

Any cache that drops or normalizes the query serves the wrong document to the wrong client: the bootstrap URL would return the legacy renderer (which the fallback then cannot talk to), or the query-free URL would return the bootstrap (which no legacy client understands).

Fix: set an explicit Cache-Control on both responses and confirm the query string is part of the Fastly cache key.

assert!(APS_RENDERER_DOCUMENT.contains("message.nonce!==expected"));
assert!(APS_RENDERER_DOCUMENT.contains("event.source !== parent"));
assert!(APS_RENDERER_DOCUMENT.contains("message.nonce !== expected"));
assert!(APS_RENDERER_DOCUMENT.contains("['nonce', 'publisherOrigin', 'renderer']"));

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.

nitpick — This asserts on Prettier's exact spacing and wrapping of a file Prettier owns. A printWidth or formatting-config change reflows the array and breaks a core Rust test for a purely cosmetic reason.

Prefer markers that survive reformatting — for example asserting on publisherOrigin and validPublisherOrigin separately, rather than on the rendered array literal.

</script>
"#;
const APS_RENDERER_DOCUMENT: &str =
include_str!("../../../trusted-server-js/lib/src/integrations/aps/renderer.html");

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.

📝 note — Core now embeds a file from the JS crate's source tree (not dist/). This works and include_str! tracks it for rebuilds, so no change needed — but the cross-crate source coupling is easy to misread as a generated artifact. A one-line comment saying the HTML is hand-written source shared with the TS bundle would save the next reader a detour.

* cannot survive the handoff. The 128-bit ID is disclosed only in that response,
* consumed once, and a newer registration revokes the same container's old ID.
*/
export function registerApsUniversalCreativeMount(

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.

👍 praise — Good doc comment. It states plainly that real PUC asks from a nested frame rather than the controller that received the authenticated response, so source-window equality cannot survive the handoff, and then explains exactly what replaces it (128-bit ID, disclosed once, consumed once, revoked by re-registration). That is the kind of note that keeps someone from "hardening" this back into a broken source check later.

const APS_INTEGRATION_ID: &str = "aps";
const APS_PUBLISHER_FRAME_ANCESTORS_CSP: &str = "frame-ancestors 'self';";

fn enforce_aps_publisher_frame_ancestors(settings: &Settings, response: &mut Response) {

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.

🌱 seedling — This is a security-sensitive gate, but the only new coverage (aps_enabled_appends_an_independent_publisher_frame_policy) exercises the enabled path. There is no negative test asserting the header is not appended when APS is absent or enabled: false, so a regression that always-appends or always-skips would still pass CI. Consider adding a disabled-case test asserting no frame-ancestors header is added.

const APS_PUBLISHER_FRAME_ANCESTORS_CSP: &str = "frame-ancestors 'self';";

fn enforce_aps_publisher_frame_ancestors(settings: &Settings, response: &mut Response) {
let aps_enabled = settings

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.

📝 note — This reads the raw integrations.aps.enabled bool directly (== Some(true)), while the rest of the codebase decides APS activation through integration_config::<ApsConfig>() / IntegrationConfig::is_enabled. They align today (the provider's is_enabled() is also just config.enabled), so behavior is correct — but it's a second source of truth for "is APS on" that could drift if APS ever gains an alternate enable path. A short comment noting the intentional coupling, or reusing the typed accessor, would guard against that.

// a publisher URL beneath the opaque renderer unless every publisher
// response rejects the cross-origin ancestor chain. Append this independent
// policy after operator headers so configuration cannot weaken it.
enforce_aps_publisher_frame_ancestors(settings, response);

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.

nitpick — The frame-ancestors 'self' policy is appended to every response type (JSON, images, the tsjs bundle), not just framed HTML. It's harmless (frame-ancestors is inert on non-framed resources) and the byte-exact dedup keeps it idempotent, but it's slightly broader than the stated "publisher document" intent. No change required — flagging for awareness.

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator Author

Follow-up fix pushed in 0fad2cde.

Real-origin reproduction and root cause

I replayed captured real APS iframe descriptors through local Trusted Server WASM on https://www.autoblog.com via ts dev proxy, using the live Amazon runner and real Prebid Universal Creative 1.17.2.

Initial direct and PUC rendering both completed: one bootstrap request, one Amazon runner request, one creative request, and PUC adRenderSucceeded. The regression was the next GPT lifecycle: after PUC committed, TSJS left the APS frame mounted and the GAM/PUC controller at display:none, so a later refresh could not reclaim the slot.

The pre-fix refresh replay retained the APS frame and hidden controller. The post-fix replay removes the committed APS frame and restores the controller (display:inline) for the exact requested slot.

Fix

  • Track committed APS mounts and exact prior sibling display value/priority.
  • On GPT slotRequested, cancel pending work, remove the committed APS frame, restore the controller, and revoke unconsumed mount capabilities for that container.
  • Force-hide the controller with inline display:none !important and restore/remove the exact prior declaration.
  • Preserve the one-shot descriptor/nonce/source/port checks and no-dual-visible-surface behavior.
  • Repair old-server rollback compatibility by posting the exact legacy { nonce, renderer } shape without abandoning a delayed modern bootstrap-ready.
  • Preflight nonce/capacity failures before replacing a working committed mount.
  • Add Firefox/WebKit APS-only CI projects while retaining the complete Next.js and WordPress suites on Chromium.

Validation

  • Real Autoblog direct iframe replay: passed with live runner and creative request.
  • Real Autoblog PUC refresh replay: passed; APS frame removed and controller restored.
  • TSJS Vitest: 838/838.
  • TSJS build, ESLint, and Prettier: passed.
  • Rust: cargo test-fastly, cargo test-axum, cargo test-cloudflare, and cargo test-spin passed.
  • Adapter parity: 13/13.
  • All six adapter Clippy targets passed.
  • APS Playwright:
    • Chromium / Next.js: 7/7
    • Chromium / WordPress: 7/7
    • Firefox + WebKit / Next.js: 14/14
  • Rustfmt, docs Prettier, workflow YAML, shell syntax, and git diff --check: passed.

Independent final review found no blocker, high, or medium findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

APS creatives requiring nested same-origin semantics render blank

3 participants