Skip to content

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

Open
aram356 wants to merge 21 commits into
mainfrom
fix/creative-sanitization-optional
Open

Make creative sanitization opt-in and restore creative iframe origin isolation#956
aram356 wants to merge 21 commits into
mainfrom
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. Opt-in: defaults to false.

2. rewrite_creatives keeps its true default — an earlier revision of this PR flipped it to false; 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's skip_serializing_if pattern: defaults are omitted from blobs, non-default values (sanitize_creatives = true, rewrite_creatives = false) are serialized.

3. allow-same-origin removed from CREATIVE_SANDBOX_TOKENS — in this PR, not a follow-up. The resulting opaque-origin iframe cannot fetch first-party endpoints (CORS, Origin: null), so:

  • the click guard recovers mutated clicks via a GET /first-party/proxy-rebuild navigation (302 chain), now registered in every adapter and tolerant of origin-form URIs; URL resolution pins document.baseURI (srcdoc inherits the parent URL; location.href is about:srcdoc), and the GET fallback never overwrites the canonical data-tsclick;
  • dynamic resource signing inside creative iframes is disabled pending a parent postMessage broker (Same-origin parent postMessage broker for dynamic signing from opaque-origin creative iframes #982); server-side rewrites are unaffected.

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

Upgrade and rollback

Binaries that predate sanitize_creatives reject 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-core lib suite 1762 passed, 0 failed; JS suite 432 passed; CLI suite (incl. new sanitize-overlay coverage) green; all six clippy targets and cargo fmt clean; cross-adapter parity tests pass
  • Regression tests for: origin-form GET rebuild 302, <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 rebuild
  • 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

ChristianPavilonis and others added 4 commits July 15, 2026 12:04
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.
@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.

Comment thread crates/trusted-server-js/lib/src/core/render.ts
Comment thread crates/trusted-server-core/src/auction_config_types.rs Outdated
Comment thread crates/trusted-server-core/src/auction_config_types.rs Outdated
Comment thread crates/trusted-server-core/src/auction/formats.rs Outdated
Comment thread crates/trusted-server-core/src/auction/formats.rs
Comment thread crates/trusted-server-core/src/auction_config_types.rs
Allow operators to retain sanitizer-accepted external URLs in POST /auction adm while preserving mandatory server-side sanitization and the existing default behavior.
@ChristianPavilonis
ChristianPavilonis force-pushed the feature/optional-creative-rewriting branch from 51a374d to e6edc23 Compare July 27, 2026 17:13
ChristianPavilonis added a commit that referenced this pull request Jul 28, 2026
# 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
aram356 added 5 commits July 28, 2026 18:24
…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.

@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

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_creatives default 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's skip_serializing_if rollback contract. Recommend the one-line option the PR description already offers — keep default_rewrite_creatives() -> true.
  • Dynamic resource proxying is now permanently off inside creative iframes (proxy_sign.ts:31): signProxyUrl returns null in an opaque origin, dynamic_src_guard.ts:91 falls 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 in docs/guide/creative-processing.md is 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:152 vs proxy_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 the adm is dropped entirely. The CHANGELOG says "subject to the 1 MiB per-creative cap" — worth the same clause in docs/guide/configuration.md and docs/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:30 still 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_rewriter rather than a sanitizer side effect is the correct fix, and process_auction_creative_rejects_oversized_markup_in_every_mode pins 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 fetch is 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 by html_escape_for_script (publisher.rs:3142), which escapes <, >, & before the bids=JSON.parse("…") inline script.
  • The GAM direct-replace path already used ADM_IFRAME_SANDBOX without allow-same-origin, so it is unaffected.
  • enableDebugFromEnv's localStorage access is already try-wrapped, so the opaque origin's SecurityError won'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

Comment thread crates/trusted-server-core/src/auction_config_types.rs Outdated
Comment thread crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts Outdated
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/auction_config_types.rs Outdated
Comment thread crates/trusted-server-js/lib/src/integrations/creative/click.ts Outdated

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

Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-js/lib/src/integrations/creative/click.ts Outdated
Comment thread crates/trusted-server-adapter-axum/src/app.rs
Comment thread crates/trusted-server-core/src/creative.rs
Comment thread docs/guide/configuration.md Outdated
Comment thread crates/trusted-server-js/lib/src/integrations/creative/click.ts
Comment thread docs/guide/configuration.md
…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.
aram356 added a commit that referenced this pull request Jul 31, 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

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:srcdoc branch 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 to false (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 canonical data-tsclick, fails diffParams, and logs click base changed; keeping original at warn level (crates/trusted-server-js/lib/src/integrations/creative/click.ts:236). I traced the loop: it terminates, href is 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 current href rather than replacing it wholesale; rebuilds a second mutation wave after an opaque-origin fallback replaces the href outright, so that shape is not covered.

⛏ nitpick

  • Spin GET route test asserts only != 404 while its comment says "the handler 302s" (crates/trusted-server-adapter-spin/tests/routes.rs:521, inline).

👍 praise

  • The Some(adm) / Some("") / None tri-state is the right shape for the reported bypass, and assert_no_render_source pins both halves — script-only under sanitization and oversized under default settings — rather than only the mode that motivated the fix.
  • <base> stripping went into rewrite_creative_html_impl, shared by the root-relative /auction pass 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.
  • persistRebuiltClick expresses the guard positively — "write data-tsclick only 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_redirects builds a real signed tsclick and asserts the 302 Location plus the applied add/del mutations, instead of the weaker "did not 404" check.

Verified non-issues

  • The GET branch validates the original tstoken through reconstruct_and_validate_signed_target before re-signing (proxy.rs:1795), so registering GET in every adapter exposes no open redirect.
  • No creative-bundle module other than proxy_sign depends on a usable origin; shared/script_guard.ts's location.origin is not reachable from the creative bundle.
  • enableDebugFromEnv's localStorage access is try-wrapped, so the opaque origin's SecurityError cannot break guard installation.
  • The /auction path yields an empty adm on rejection and carries no cache coordinates, so it has no parallel to the build_bid_map bypass.
  • 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

Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-js/lib/src/shared/origin.ts
Comment thread docs/guide/creative-processing.md
Comment thread crates/trusted-server-adapter-spin/tests/routes.rs

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

Comment thread crates/trusted-server-js/lib/src/integrations/creative/click.ts
Comment thread crates/trusted-server-adapter-axum/src/app.rs
Comment thread crates/trusted-server-core/src/creative.rs
Comment thread crates/trusted-server-js/lib/src/shared/origin.ts
Comment thread crates/trusted-server-core/src/creative.rs
Comment thread crates/trusted-server-js/lib/src/core/render.ts
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/creative.rs
Comment thread docs/guide/auction-orchestration.md
@aram356
aram356 deleted the branch main August 1, 2026 05:05
@aram356 aram356 closed this Aug 1, 2026
aram356 added a commit that referenced this pull request Aug 1, 2026
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.
@aram356 aram356 reopened this Aug 1, 2026
@aram356
aram356 changed the base branch from feature/optional-creative-rewriting to main August 1, 2026 20:29
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.
Comment thread crates/trusted-server-js/lib/src/integrations/creative/click.ts Fixed
Comment thread crates/trusted-server-js/lib/src/integrations/creative/click.ts Fixed
aram356 added 4 commits August 1, 2026 13:57
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 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 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 as http://[::1]:7676, causing opaque-origin creatives to fall back to the <base>-sensitive document.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;

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

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

Approved. The previously submitted inline comments are non-blocking observations; I found no issues that should prevent merge.

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

4 participants