Skip to content

Make creative sanitization opt-in and restore creative iframe origin isolation#956

Open
aram356 wants to merge 1 commit into
feature/optional-creative-rewritingfrom
fix/creative-sanitization-optional
Open

Make creative sanitization opt-in and restore creative iframe origin isolation#956
aram356 wants to merge 1 commit into
feature/optional-creative-rewritingfrom
fix/creative-sanitization-optional

Conversation

@aram356

@aram356 aram356 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes #955
Stacks on #916 — targets feature/optional-creative-rewriting, extending the same "creative processing is opt-in" idea from rewriting to sanitization.

Summary

Creative sanitization runs unconditionally on every markup bid, stripping script/object/embed/form and friends together with their inner content. For script-based creatives — the majority of programmatic display — that leaves nothing renderable, and the slot goes blank with no error: what survives is usually a tracking pixel, so adm is non-empty and the ad server records a normal impression.

Measured over 291 creative deliveries on a live publisher: a median 43% of bytes removed, 29 creatives reduced by more than 80%, and 20 reduced below 500 bytes. One bidder lost 100% of every creative; another lost 76% across 43 of them.

Changes

1. auction.sanitize_creatives — gates sanitize_creative_html, independent of rewrite_creatives. Lets a publisher disable stripping when creatives render in a foreign-origin frame (the Prebid Universal Creative inside the ad server's iframe — the context rewrite_creative_html already documents), where the markup cannot reach the publisher origin.

2. Both creative controls are opt-insanitize_creatives and rewrite_creatives default to false, so a creative ships exactly as the bidder returned it unless the publisher asks for processing.

3. allow-same-origin removed from CREATIVE_SANDBOX_TOKENS — in this PR, not a follow-up.

It also mirrors this branch's skip_serializing_if pattern onto the new field, so a blob only carries an explicit opt-in. Note the flipped default inverts which value is non-default: rewrite_creatives: true is now the value worth preserving, so disabled_rewrite_creatives_is_serialized became enabled_rewrite_creatives_is_serialized.

Why the sandbox change belongs here

Sanitization is documented as "the primary defense against malicious markup", with the sandbox as defense-in-depth. But the sandbox granted allow-same-origin alongside allow-scripts, which removes its origin isolation entirely — the codebase says so itself on ADM_IFRAME_SANDBOX, and APS_RENDERER_SANDBOX omits the token for the same reason. CREATIVE_SANDBOX_TOKENS was the only one granting it.

With sanitization now optional, that pairing would leave creative markup able to reach publisher cookies, storage, and same-origin fetches. The client-side sanitizeCreativeHtml is no backstop — it validates type and emptiness only and returns input unchanged (removedCount is always 0). So the origin boundary moves to isolation, where it no longer depends on an optional transform.

Behaviour change to review

Flipping rewrite_creatives inverts an existing backward-compatibility guarantee: legacy_blob_without_rewrite_creatives_preserves_rewriting asserted that deployments whose stored config blob predates the field keep rewriting enabled. That test is updated, and the practical effect is that any deployment which never set rewrite_creatives loses creative URL rewriting — first-party proxying, click conversion, and creative TSJS injection.

If that is not wanted for other tenants, rewrite_creatives should keep defaulting to true while sanitize_creatives defaults to false. One-line change on this branch — flagging explicitly for a decision.

Verification

  • trusted-server-core lib suite 1663 passed, 0 failed; JS suite 411 passed; cargo fmt clean
  • Tests that encoded the old "sanitization always runs" contract were updated to set the flags explicitly
  • End to end through the dev proxy against a live publisher: sanitize disabled, rewrite disabled with raw == sanitized == output on every creative (8902 → 8902 and 22069 → 22069 for bidders previously losing 100% and 35%), page serving ads with no hydration errors

…isolation

Creative sanitization ran unconditionally on every markup bid, stripping
`script`/`object`/`embed`/`form` and friends together with their inner content.
For script-based creatives — the majority of programmatic display — that leaves
nothing renderable, and the slot goes blank with no error: the leftover markup is
usually a tracking pixel, so the ad server reports a successful render.

Measured over 291 creative deliveries on a live publisher: a median 43% of bytes
removed, 29 creatives reduced by more than 80%, and 20 reduced below 500 bytes.
One bidder lost 100% of every creative; another lost 76% across 43 of them.

Add `auction.sanitize_creatives` so sanitization can be disabled where creatives
render in a foreign-origin frame (the Prebid Universal Creative inside the ad
server's iframe), and make both creative controls opt-in: `sanitize_creatives`
and `rewrite_creatives` now default to false, so a creative ships exactly as the
bidder returned it unless a publisher asks for processing.

Removing `allow-same-origin` from the creative iframe sandbox is part of the same
change rather than a follow-up. Sanitization was documented as "the primary
defense against malicious markup", with the sandbox as defense-in-depth — but the
sandbox granted `allow-same-origin` alongside `allow-scripts`, which removes its
origin isolation entirely. With sanitization now optional, that pairing would
leave creative markup able to reach publisher cookies, storage, and same-origin
fetches. The two sibling sandboxes (APS_RENDERER_SANDBOX, ADM_IFRAME_SANDBOX)
already omit the token for exactly this reason; this brings the third in line, so
the origin boundary no longer depends on an optional transform.

Note the default change alters behaviour for deployments that never set
`rewrite_creatives`: creative URL rewriting is now off unless enabled explicitly.

Verified end to end: creatives pass through byte-for-byte (triplelift 8902 ->
8902, openx 22069 -> 22069, previously 100% and 35% losses), page renders with
ads serving and no hydration errors.
@aram356
aram356 force-pushed the fix/creative-sanitization-optional branch from 951799e to b4f5def Compare July 22, 2026 18:54
@aram356
aram356 changed the base branch from rc/july to feature/optional-creative-rewriting July 22, 2026 18:54
@aram356 aram356 self-assigned this Jul 23, 2026

@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

The creative-processing behavior change looks coherent: sanitization and rewriting are independently opt-in, and the creative iframe sandbox no longer combines allow-scripts with allow-same-origin. I did not find blocking issues in the runtime/config changes.

Non-blocking

📝 note

  • Update stale auction docs/comments for the new creative-processing defaults: crates/trusted-server-core/src/auction/README.md still describes the old contract: creative HTML is always sanitized, rewriting is enabled by default, and [auction].rewrite_creatives = false skips rewriting but not sanitization. That is now stale with this PR: both sanitize_creatives and rewrite_creatives default to false, and sanitization can be disabled independently. Please update the operator-facing README language around crates/trusted-server-core/src/auction/README.md:252 and crates/trusted-server-core/src/auction/README.md:386 to describe the new matrix: default raw bidder adm, sanitize_creatives = true strips executable markup, and rewrite_creatives = true applies proxy/click/runtime rewriting to the current creative HTML.

CI Status

  • browser integration tests: PASS
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • prepare integration artifacts: PASS
  • targeted local regression check: PASS (cargo test --package trusted-server-cli --target aarch64-apple-darwin migrated_legacy_config_applies_rewrite_creatives_environment_override)

@ChristianPavilonis ChristianPavilonis 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

I found one confirmed blocker in the opaque-origin creative runtime, along with high-risk upgrade and rollback compatibility problems. The sandbox isolation direction is sound, but rewritten clicks can currently fail, existing configurations silently change behavior, and the documented rollback sequence is now unsafe. Please address the inline findings before merge.

// origin isolation and would let SSP-provided markup run with the publisher
// origin's privileges — cookies, storage, and same-origin fetches. The origin
// boundary must not depend on server-side sanitization, which is optional
// (`auction.sanitize_creatives`) and cannot run at all for renderer-based bids.

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.

🔧 P0 — The opaque-origin sandbox breaks rewritten click recovery

Removing allow-same-origin correctly gives this srcdoc creative an opaque origin, but the injected click guard still sends an application/json POST to /first-party/proxy-rebuild. That request preflights with Origin: null, while the endpoint supplies no CORS response. On failure, the runtime navigates to GET /first-party/proxy-rebuild; core supports that GET as a 302 fallback, but every adapter registers the route for POST only. Mutated rewritten clicks can therefore fall through to the publisher origin and end at a 404/error instead of the advertiser. Dynamic image/iframe signing has the same CORS problem when renderGuard is enabled.

Suggested fix: Do not restore allow-same-origin. Register the existing GET rebuild handler in every adapter and make opaque-origin clicks use that navigation fallback. Use a narrowly validated same-origin parent postMessage broker for dynamic signing rather than blanket CORS for Origin: null. Add a real-browser regression test for a mutated rewritten click from this sandboxed srcdoc.


fn default_rewrite_creatives() -> bool {
true
false

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.

🔧 P1 — Omitted configuration fields silently disable existing processing on upgrade

Both creative controls now default to false. Every pre-existing deployment omits sanitize_creatives, and blobs produced under this PR's actual base commonly omit its default-true rewrite_creatives. Those blobs therefore deserialize with both operations disabled: mandatory sanitization becomes raw executable bidder markup, while proxy/click rewriting and runtime injection disappear. The sandbox change protects only Trusted Server's own iframe path; /auction is also consumed by programmatic renderers whose isolation is not enforced here.

Suggested fix: Keep sanitize_creatives defaulting to true so the risky behavior is an explicit opt-out, and preserve the base's default-true rewriting unless a breaking migration has been explicitly approved. If false defaults are retained, require a staged configuration migration and release-note the security and behavioral impact.

serialized.get("rewrite_creatives"),
Some(&serde_json::Value::Bool(false)),
"should preserve an explicit rewrite opt-out"
Some(&serde_json::Value::Bool(true)),

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.

🔧 P1 — The documented rollback procedure now creates blobs older binaries reject

The current operator guidance says rewrite_creatives = true is omitted and instructs operators to push true before rollback. This test proves the new serialization behavior is the inverse: true is emitted while false is omitted. Older auction schemas use deny_unknown_fields, and the immediate base similarly rejects a serialized sanitize_creatives. Following the published emergency rollback procedure can therefore make configuration loading fail.

Suggested fix: Add and test explicit downgrade sequences. Before reverting to the immediate base, push sanitize_creatives = false so that field is omitted. Before reverting to a binary knowing neither field, ensure both fields are omitted. Update the configuration guide, auction guide, and changelog accordingly.

let sanitized = if sanitize_creatives {
creative::sanitize_creative_html(raw_creative)
} else {
raw_creative.clone()

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.

🔧 P2 — Disabling sanitization also bypasses the creative-size safety limit

The deliberate 1 MiB MAX_CREATIVE_SIZE check exists only inside sanitize_creative_html, so both unsanitized modes bypass it. With rewriting enabled, the larger input is also passed into rewrite_creative_html; with rewriting disabled, this clone and subsequent JSON serialization still add allocations in constrained WASM memory. RTB responses have a 2 MiB aggregate cap, but this removes the lower per-creative invariant and can compound across providers.

Suggested fix: Enforce a processing-independent creative-size limit before branching on either flag, and test oversized creatives in every supported mode.

// markup cannot reach the publisher origin — can opt out and deliver the
// creative exactly as the bidder returned it.
let mut settings = make_settings();
settings.auction.sanitize_creatives = false;

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.

🔧 P2 — The fourth processing mode and byte-for-byte contract are untested

The tests cover (sanitize, rewrite) combinations (true,true), (true,false), and (false,false), but not (false,true), which feeds executable bidder markup directly into the rewriter. This pass-through test also checks only that two markers remain rather than proving the stated byte-for-byte contract.

Suggested fix: Add a (false,true) test covering scripts, event handlers, resource/click rewriting, malformed input, and size limits. For (false,false), assert that the parsed response adm exactly equals the original bidder input.

///
/// Disable only when creatives render in a foreign-origin frame (for example the
/// Prebid Universal Creative inside the ad server's iframe), where the markup
/// cannot reach the publisher origin. Defaults to disabled.

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.

📝 P2 — Security-critical API and client documentation still promises mandatory sanitization

Beyond the auction README already called out in another review, the endpoint Rustdoc, response-converter docs, configuration guide, creative-processing guide, auction-orchestration guide, changelog, and client comments still state that creative markup is always sanitized and rewriting defaults on. Client code also describes the incoming markup as already sanitized and non-executable. That guidance is now the opposite of the runtime default and can cause operators or future maintainers to treat executable bidder markup as trusted.

Suggested fix: Update all public guidance to document the four-mode matrix, defaults, sandbox requirements, direct-resource privacy implications, and migration/rollback behavior. Rename or clearly describe the client sanitizeCreativeHtml helper as validation-only.

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.

Creative sanitization silently blanks script-based creatives; creative iframe sandbox grants allow-same-origin

3 participants