Make creative sanitization opt-in and restore creative iframe origin isolation - #956
Make creative sanitization opt-in and restore creative iframe origin isolation#956aram356 wants to merge 21 commits into
Conversation
Allow operators to retain sanitizer-accepted external URLs in POST /auction adm while preserving mandatory server-side sanitization and the existing default behavior.
…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.
951799e to
b4f5def
Compare
prk-Jr
left a comment
There was a problem hiding this comment.
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.mdstill describes the old contract: creative HTML is always sanitized, rewriting is enabled by default, and[auction].rewrite_creatives = falseskips rewriting but not sanitization. That is now stale with this PR: bothsanitize_creativesandrewrite_creativesdefault tofalse, and sanitization can be disabled independently. Please update the operator-facing README language aroundcrates/trusted-server-core/src/auction/README.md:252andcrates/trusted-server-core/src/auction/README.md:386to describe the new matrix: default raw bidderadm,sanitize_creatives = truestrips executable markup, andrewrite_creatives = trueapplies 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
left a comment
There was a problem hiding this comment.
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.
Allow operators to retain sanitizer-accepted external URLs in POST /auction adm while preserving mandatory server-side sanitization and the existing default behavior.
51a374d to
e6edc23
Compare
# Conflicts: # crates/trusted-server-cli/tests/config_env_overlay.rs # crates/trusted-server-core/src/auction/formats.rs # crates/trusted-server-core/src/auction_config_types.rs # crates/trusted-server-core/src/config_payload.rs # trusted-server.example.toml
…sanitization-optional Keep both creative-processing controls opt-in (default false) while adopting the base branch's consolidation of auction creative processing into creative::process_auction_creative, which the publisher SSAT/page-bids inline path shares. The shared helper now gates sanitization on sanitize_creatives. Base-side tests that relied on mandatory sanitization or default-true rewriting set the flags explicitly; docs describe the four-mode matrix.
…iants Address review feedback on the opt-in creative-processing change: - Register GET /first-party/proxy-rebuild in every adapter. The creative iframe sandbox no longer grants allow-same-origin, so the click guard's JSON POST preflights with Origin: null and fails; the guard now recovers by navigating to the GET endpoint, which the core handler answers with a 302 chain that CORS does not apply to. - Make the click guard detect an opaque origin up front and skip the doomed POST, going straight to the GET navigation fallback. Dynamic resource signing likewise bails out early instead of issuing a request that cannot succeed; a same-origin parent postMessage broker for opaque-origin signing is tracked as a follow-up. - Enforce the 1 MiB per-creative cap independently of the sanitize/rewrite flags so pass-through and rewrite-only modes cannot ship oversized markup. - Cover the remaining processing modes: rewrite-without-sanitize at both the helper and response-converter level, a byte-for-byte pass-through assertion, and oversized-creative rejection in all four modes. - Update stale documentation that still promised mandatory sanitization and default-on rewriting; describe the client sanitizeCreativeHtml helper as validation-only and add a breaking-change changelog entry with the upgrade and rollback sequences.
…sanitization-optional
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
Adds auction.sanitize_creatives, makes both creative-processing controls opt-in, removes allow-same-origin from CREATIVE_SANDBOX_TOKENS, and recovers the resulting opaque-origin click path via a GET /first-party/proxy-rebuild navigation fallback registered in all four adapters.
The sandbox change is the right call and is correctly scoped: the core handler already answered GET with a 302 (proxy.rs:1899) and validates the original tstoken before re-signing, so the new registrations expose no new capability. Every finding from the previous round is genuinely addressed — the 1 MiB cap is hoisted out of the sanitizer into the shared helper, the (false, true) mode is covered at both levels, the pass-through test asserts a real assert_eq! against the bidder input, and the doc sweep is thorough.
Two things are still blocking, one substantive and one about what the docs promise.
Blocking
🔧 wrench
rewrite_creativesdefault flip is unrelated to the bug being fixed (auction_config_types.rs:92): the measurement justifies making sanitization opt-in; flipping rewriting too silently disables first-party proxying, click conversion and creative TSJS injection for every existing deployment, and inverts the base branch'sskip_serializing_ifrollback contract. Recommend the one-line option the PR description already offers — keepdefault_rewrite_creatives() -> true.- Dynamic resource proxying is now permanently off inside creative iframes (
proxy_sign.ts:31):signProxyUrlreturnsnullin an opaque origin,dynamic_src_guard.ts:91falls back to the raw URL, and runtime-injected<img>/<iframe>load direct from third parties. Deferring the postMessage broker is fine; landing it without a tracked issue number and without saying so indocs/guide/creative-processing.mdis not — the guide still advertises the injected runtime's click/resource protections unqualified.
Non-blocking
♻️ refactor
- Redundant flag in
build_bid_map_omits_oversized_adm(publisher.rs:7564): the cap is unconditional now, so the override only removes coverage of the shipped default mode. - Duplicated opaque-origin predicate (
click.ts:152vsproxy_sign.ts:27), with divergent catch-path semantics.
🤔 thinking
- The four-mode matrix omits the cap: both guides describe the default
(false, false)mode as "deliver the creative exactly as the bidder returned it", but a creative over 1 MiB now yields an empty string and theadmis dropped entirely. The CHANGELOG says "subject to the 1 MiB per-creative cap" — worth the same clause indocs/guide/configuration.mdanddocs/guide/creative-processing.md, since those tables are what operators read when choosing a mode.
⛏ nitpick
- Two "sanitized, non-executable" claims survived the doc sweep, both in security-relevant comments and neither reachable by an inline comment since the lines are untouched:
crates/trusted-server-js/lib/src/core/render.ts:177— "Construct a sandboxed iframe sized for sanitized, non-executable creative HTML."crates/trusted-server-js/lib/src/core/request.ts:82— "Render a creative by writing sanitized, non-executable HTML into a sandboxed iframe."
auction_config_types.rs:30still documents the field as rewriting sanitized HTML.
👍 praise
- The cap moved to the right layer. Treating the 1 MiB limit as a delivery invariant in
process_auction_creative_with_rewriterrather than a sanitizer side effect is the correct fix, andprocess_auction_creative_rejects_oversized_markup_in_every_modepins it across all four combinations instead of just the new one. - The pass-through test proves the actual contract.
assert_eq!(adm, original)at both the helper and response-converter level is what makes "ships exactly as the bidder returned it" a testable claim rather than a marker check. - Opaque-origin recovery is covered end to end — unit tests asserting
fetchis never called, plus GET route registration verified per adapter (including a dedicated Spin route test) rather than assumed from the core handler.
Verified non-issues
</script>breakout from raw bidder markup into the publisher origin: blocked byhtml_escape_for_script(publisher.rs:3142), which escapes<,>,&before thebids=JSON.parse("…")inline script.- The GAM direct-replace path already used
ADM_IFRAME_SANDBOXwithoutallow-same-origin, so it is unaffected. enableDebugFromEnv'slocalStorageaccess is alreadytry-wrapped, so the opaque origin'sSecurityErrorwon't take down the click guard at install time.
CI Status
- fmt: PASS
- clippy / cargo check (fastly, axum, cloudflare native + wasm, spin native + wasm): PASS
- rust tests (fastly, axum, cloudflare, spin, ts CLI, cross-adapter parity): PASS
- js tests (vitest) + format-typescript + format-docs: PASS
- integration tests (browser, Fastly EC lifecycle, prepare artifacts): PASS
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
I found seven additional issues in the current head: one security/control-bypass blocker, four high-severity correctness or compatibility failures, and two medium-severity regressions. In particular, rejected creatives can be restored raw from PBS Cache, and the opaque-origin click recovery is broken independently in the browser runtime and in origin-form request handling. All findings are inline.
…covery Address the second review round: - Keep rewrite_creatives defaulting to true. The measurement behind this PR justifies making sanitization opt-in; flipping rewriting too would silently disable first-party proxying for every existing deployment and invert the rollback serialization contract. Only sanitize_creatives (default false) is new behavior. - Suppress the PBS Cache fallback when a supplied creative is rejected by processing (script-only after sanitize, malformed, or over the 1 MiB cap). The GPT bridge renders the cached bid's original adm, so retaining the hb_cache_* coordinates would deliver exactly the markup processing refused. Cache coordinates now ship only for bids that supplied no creative. - Parse the GET /first-party/proxy-rebuild query from uri().query() instead of the full URI: browsers via the Axum and Spin adapters deliver origin-form URIs, which url::Url::parse rejected before the 302 could be issued. - Strip bidder <base> elements in the rewrite pass itself. Rewriting emits root-relative first-party URLs, and a bidder base href would rebase them onto a third-party origin; sanitization also removes <base> but is independently optional. - Resolve click-guard URLs against a pinned document.baseURI instead of location.href, which is about:srcdoc inside the sandboxed creative iframe and rejects root-relative signed click URLs as a base. Navigation URLs are absolutized the same way. - Never write the GET rebuild fallback into data-tsclick: it is the canonical signed click used to diff later mutation waves. href updates, the canonical source persists, and a second mutation wave still rebuilds. - Share one hasOpaqueOrigin predicate between the click guard and dynamic signing; reference issue #982 (parent postMessage broker) from the code and the creative-processing guide's new runtime-protections note. - Document upgrade sequencing (binary first, then config: old binaries reject blobs carrying sanitize_creatives), the per-mode 1 MiB cap, and the requirement to add both TOML leaves before environment overlays apply; add CLI coverage for the sanitize overlay.
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
Every finding from the previous round is genuinely addressed. build_bid_map now distinguishes absent / accepted / rejected creatives, the GET rebuild parses uri().query() so origin-form request targets work, <base> stripping moved into the shared rewrite_creative_html_impl (so it is independent of sanitization), TRUSTED_BASE_URL pins resolution to document.baseURI, persistRebuiltClick keeps the canonical data-tsclick out of the fallback's reach, rewrite_creatives is back to its default true, and the #982 deferral is tracked in both the code and the guide. The doc sweep, upgrade sequencing, and CLI overlay coverage are all in place.
One question about a behavior change that goes slightly beyond what was asked, plus three non-blocking observations. Nothing blocking.
Blocking
❓ question
hb_cache_*is dropped for accepted creatives, not just rejected ones — the cache-fallback fix also removes the coordinates on the normal accepted path, which are GPT slot targeting keys the Prebid Universal Creative reads directly. Likely intended, but undocumented (crates/trusted-server-core/src/publisher.rs:3259, inline).
Non-blocking
🤔 thinking
- The
about:srcdocbranch is untested — jsdom never exercises it, and the Playwright/chromium harness the deferral note says is missing already runs in CI (crates/trusted-server-js/lib/src/shared/origin.ts:24, inline). - #982's blast radius is narrower than the note implies — dynamic signing only installs under
renderGuard, which defaults tofalse(docs/guide/creative-processing.md:89, inline). - Post-fallback mutation diffing degrades to a warn. Once the opaque-origin fallback is written into
href, every later observer pass canonicalizes/first-party/proxy-rebuild?...against the canonicaldata-tsclick, failsdiffParams, and logsclick base changed; keeping originalat warn level (crates/trusted-server-js/lib/src/integrations/creative/click.ts:236). I traced the loop: it terminates,hrefis not reverted, and the second wave still rebuilds — so this is correct, not a defect. The residual gap is a creative that mutates by appending to the currenthrefrather than replacing it wholesale;rebuilds a second mutation wave after an opaque-origin fallbackreplaces the href outright, so that shape is not covered.
⛏ nitpick
- Spin GET route test asserts only
!= 404while its comment says "the handler 302s" (crates/trusted-server-adapter-spin/tests/routes.rs:521, inline).
👍 praise
- The
Some(adm)/Some("")/Nonetri-state is the right shape for the reported bypass, andassert_no_render_sourcepins both halves — script-only under sanitization and oversized under default settings — rather than only the mode that motivated the fix. <base>stripping went intorewrite_creative_html_impl, shared by the root-relative/auctionpass and the absolute-URL inline pass, with a test at each level. Putting it in the shared impl is what makes "rewriting must not depend on sanitization" structurally true instead of a convention.persistRebuiltClickexpresses the guard positively — "writedata-tsclickonly if this is itself a signed/first-party/click" — rather than blacklisting the rebuild URL. That stays correct if another fallback shape is ever added.proxy_rebuild_get_with_origin_form_uri_redirectsbuilds a real signedtsclickand asserts the 302Locationplus the applied add/del mutations, instead of the weaker "did not 404" check.
Verified non-issues
- The GET branch validates the original
tstokenthroughreconstruct_and_validate_signed_targetbefore re-signing (proxy.rs:1795), so registering GET in every adapter exposes no open redirect. - No creative-bundle module other than
proxy_signdepends on a usable origin;shared/script_guard.ts'slocation.originis not reachable from the creative bundle. enableDebugFromEnv'slocalStorageaccess istry-wrapped, so the opaque origin'sSecurityErrorcannot break guard installation.- The
/auctionpath yields an emptyadmon rejection and carries no cache coordinates, so it has no parallel to thebuild_bid_mapbypass. - The 1 MiB cap is applied after
${AUCTION_PRICE}expansion, so macro-driven growth cannot slip past it.
CI Status
- fmt: PASS
- clippy / cargo check (cloudflare native + wasm32-unknown-unknown, spin native + wasm32-wasip1): PASS
- rust tests (fastly, axum, cloudflare, spin, ts CLI, cross-adapter parity): PASS
- js tests (vitest) + format-typescript + format-docs: PASS
- integration tests (browser, Fastly EC lifecycle, prepare artifacts): PASS
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
I found six high-severity and three medium-severity issues in the current head. The sandbox-isolation direction is sound, but observer-repaired clicks still lose their mutations at navigation time, Axum cannot complete the new redirect chain, and several common creative shapes or resource types do not work under the opaque-origin design. I reproduced the primary click failure with an isolated Vitest scenario.
All reported CI checks are currently passing, but the test suite does not exercise these end-to-end paths.
Brings in the #988 browser-spec fix through its PR lineage and, because #988 stacks on #963's head, refreshes rc's stale #963 absorption with the July 29-30 rework: - bid.meta second descriptor carrier and bidAccepted registration replacing the requestId stash (prebid shim and Rust provider) - Hardened APS auction delivery: sanitized publisher page identity (query/fragment stripped), delivery drop telemetry with dropped_winner_count/reasons, imp disposition counters - ProviderLaunchState/ProviderRequestOutcome orchestrator refactor with parse_state threading and Immediate outcomes - as_aps() Option accessor, fail-closed render-bridge stop, responsive slot-root helpers, case-insensitive APS exclusion tests Preserved rc-only systems the #963 branch predates: #956 opt-in creative processing (process_auction_creative, sanitize_creatives), #967 decoupled prebid shim (public markWinningBidAsUsed instead of prebid.js internals), #948/#912 GPT sync, #865 platform timeout canonicalization (restored at both launch paths and both mediator paths, with the duplicate backend-name pre/post-launch guards and their test suite ported to the new provider API), and the provider-validation startup checks.
PR #916 was squash-merged into main and this PR was retargeted to main, so the previously merged base content re-conflicted without shared history. Every conflicted region resolves to this branch's version, which already contains the base content plus this PR's changes; the only main-side delta adopted inside a conflicted file is the pub(crate) visibility on process_auction_creative. Main's GPT diagnostics overlay (#974) and lint scope (#984) changes merged cleanly.
CodeQL flagged the click guard's navigation and href-persist sinks: the inputs are creative-controlled DOM attributes, so a javascript: value in data-tsclick or href could reach location.href or be written back as an anchor href. Resolve every candidate URL against the pinned trusted base and require an http(s) scheme before navigating or persisting, failing closed otherwise. Also replace an as-any cast in the new click test now that main lints the full JS package (#984).
CodeQL still flagged the href write in persistRebuiltClick: it validated the candidate URL but then wrote the original creative-controlled string. Write the sanitizer's resolved output instead — the http(s)-checked URL absolutized against the pinned trusted base. Beyond closing the taint flow, an absolute href keeps the anchor's default navigation working inside the srcdoc iframe, where a relative value would resolve against about:srcdoc. Tests updated to expect the absolute forms.
- Navigate the observer-repaired click. The mutation observer writes the GET rebuild fallback to href while keeping the canonical signed click in data-tsclick; a later click canonicalized the fallback against that canonical URL, failed the base comparison, and navigated the pre-mutation click. Remember the pending rebuild per anchor and navigate it, and skip no-op attribute writes that would otherwise wake the observer in a loop. - Accept origin-form request targets in the shared signed-target parser. Browsers send /path?query and the Axum adapter forwards it verbatim, so url::Url::parse rejected it as relative — breaking /first-party/click, /first-party/proxy and GET /first-party/sign there, including the second hop of the new rebuild redirect chain. - Inject the click-guard runtime into body-less creative fragments. lol_html matches no <body> in a bare fragment, so common adm shapes shipped without the guard while surviving bidder script could still mutate rewritten links. - Bound rewritten output, not just raw input: rewriting expands every URL into a signed proxy/click URL, so a sub-cap creative could amplify well past it. Reject once the output exceeds the cap. - Fail closed on rewriter errors instead of returning partially rewritten markup, matching the sanitizer. - Treat an explicit empty adm as a supplied creative, not an absent one, so it cannot re-enable the raw PBS Cache fallback without a rejection. - Stamp the first-party origin into the srcdoc document from the parent page and prefer it, then location.origin, over the inherited document.baseURI, which honours a publisher <base> and is not a trustworthy boundary. - Allow proxied assets to load cross-origin. The opaque creative origin makes /first-party/proxy cross-origin, blocking CORS-mode subresources; assets are fetched without client credentials, so a wildcard allow is sound. - Docs: correct the remaining stale auction-orchestration sections, note that #982 only affects deployments with renderGuard enabled, document the cache coordinate policy, and scope the Spin route test's comment to what it asserts.
The srcdoc/opaque-origin conditions the click-guard recovery depends on cannot be reproduced in jsdom: document.baseURI stays an ordinary http URL and window.origin is a real origin, so the about:srcdoc branch and the CORS-blocked POST are never exercised. The Playwright harness already runs in CI, so pin the path there: build the sandboxed srcdoc the client builds (stamped origin, runtime, root-relative signed click), mutate the link, click it, and assert the iframe navigates to /first-party/proxy-rebuild carrying the mutation.
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
The creative-processing and sandbox changes are coherent and well-covered. I found no blocking issues; the PR is merge-ready, with one IPv6-origin edge case worth addressing or tracking.
Non-blocking
🤔 thinking
- Valid IPv6 origins bypass the trusted-origin stamp (
crates/trusted-server-js/lib/src/core/render.ts:234): the DNS-only regular expression rejects valid origins such ashttp://[::1]:7676, causing opaque-origin creatives to fall back to the<base>-sensitivedocument.baseURI.
👍 praise
- Creative rejection cannot bypass processing through PBS Cache (
crates/trusted-server-core/src/publisher.rs:3278): distinguishing an accepted creative, a rejected supplied creative, and an absent creative preserves legitimate cache-only bids while preventing delivery of raw markup that processing rejected.
CI Status
- fmt and documentation/TypeScript formatting: PASS
- Rust checks, clippy, and adapter tests: PASS
- CLI and cross-adapter tests: PASS
- Vitest: PASS
- Browser and integration tests: PASS
- CodeQL: PASS
| function trustedCreativeOrigin(): string { | ||
| try { | ||
| const origin = location.origin; | ||
| if (/^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(origin)) return origin; |
There was a problem hiding this comment.
🤔 thinking — This validator rejects valid IPv6 origins such as http://[::1]:7676. In that case the stamp is empty, and the opaque-origin runtime falls back to document.baseURI, which is explicitly <base>-sensitive and not considered a trustworthy boundary. This can resolve root-relative click recovery against the wrong origin when the publisher uses a cross-origin <base>. Consider validating with URL and safely serializing its normalized origin into the template rather than encoding origin grammar in a regex; an IPv6-origin test would pin the behavior.
| let processed_adm = bid.creative.as_ref().map(|raw_creative| { | ||
| // Resolve ${AUCTION_PRICE} from the exact winning CPM BEFORE | ||
| // sanitizing, rewriting, and signing — URL rewriting would | ||
| // otherwise encode the literal macro into the signed proxy/click |
There was a problem hiding this comment.
👍 praise — The Some(accepted) / Some(rejected) / None distinction is exactly the right model here. It preserves PBS Cache coordinates for genuinely cache-only bids while ensuring a supplied creative that was sanitized, rewritten, or rejected can never be fetched again as raw markup.
prk-Jr
left a comment
There was a problem hiding this comment.
Approved. The previously submitted inline comments are non-blocking observations; I found no issues that should prevent merge.
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/formand 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, soadmis 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— gatessanitize_creative_html, independent ofrewrite_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 contextrewrite_creative_htmlalready documents), where the markup cannot reach the publisher origin. Opt-in: defaults tofalse.2.
rewrite_creativeskeeps itstruedefault — an earlier revision of this PR flipped it tofalse; both reviewers asked to keep the base branch's default (the measurement above justifies opt-in sanitization, not disabling rewriting), so only sanitization behavior changes for existing deployments. The new field mirrors the branch'sskip_serializing_ifpattern: defaults are omitted from blobs, non-default values (sanitize_creatives = true,rewrite_creatives = false) are serialized.3.
allow-same-originremoved fromCREATIVE_SANDBOX_TOKENS— in this PR, not a follow-up. The resulting opaque-origin iframe cannotfetchfirst-party endpoints (CORS,Origin: null), so:/first-party/proxy-rebuildnavigation (302 chain), now registered in every adapter and tolerant of origin-form URIs; URL resolution pinsdocument.baseURI(srcdoc inherits the parent URL;location.hrefisabout:srcdoc), and the GET fallback never overwrites the canonicaldata-tsclick;4. Processing invariants hardened — the 1 MiB per-creative cap is enforced in every mode (not just inside the sanitizer); a supplied creative that processing rejects suppresses the PBS Cache fallback (which would otherwise deliver the raw cached
adm); and the rewrite pass strips bidder<base>elements itself, since a base href would rebase the emitted root-relative first-party URLs onto a third-party origin.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-originalongsideallow-scripts, which removes its origin isolation entirely — the codebase says so itself onADM_IFRAME_SANDBOX, andAPS_RENDERER_SANDBOXomits the token for the same reason.CREATIVE_SANDBOX_TOKENSwas 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
sanitizeCreativeHtmlis no backstop — it validates type and emptiness only and returns input unchanged (removedCountis always 0). So the origin boundary moves to isolation, where it no longer depends on an optional transform.Upgrade and rollback
Binaries that predate
sanitize_creativesreject a blob carrying it (deny_unknown_fields), so upgrade the binary first, then push config; the configuration guide documents the sequencing and the unsafe interval. Before rolling back to a binary that predates a field, restore its default and push the default-compatible blob. Environment overlays require both[auction]leaves to exist in the TOML (EdgeZero v0.0.4 cannot create missing leaves).Verification
trusted-server-corelib suite 1762 passed, 0 failed; JS suite 432 passed; CLI suite (incl. new sanitize-overlay coverage) green; all six clippy targets andcargo fmtclean; cross-adapter parity tests pass<base>stripping in both rewrite passes, PBS Cache fallback suppression (script-only / oversized) and retention (absent creative), byte-for-byte pass-through, all four processing modes, oversized rejection per mode, opaque-origin click fallback and two-wave mutation rebuildsanitize disabled, rewrite disabledwith 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