diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ec85b96ac..4973afe44 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -183,7 +183,16 @@ jobs: with: node-version: ${{ steps.shared-setup.outputs.node-version }} cache: npm - cache-dependency-path: crates/trusted-server-integration-tests/browser/package-lock.json + cache-dependency-path: | + crates/trusted-server-integration-tests/browser/package-lock.json + crates/trusted-server-js/lib/package-lock.json + + - name: Build TSJS browser fixtures + working-directory: crates/trusted-server-js/lib + run: | + npm ci + npm run build + npm run build:prebid-external - name: Install Playwright working-directory: crates/trusted-server-integration-tests/browser diff --git a/CHANGELOG.md b/CHANGELOG.md index e0261cf72..47cdd30c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape. - **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries. - **Breaking** — Integration configuration strings are no longer globally reinterpreted as JSON scalars. Operators upgrading should audit `[integrations.*]` settings and use native TOML/typed-config booleans and numbers (for example, `enabled = true`, not `enabled = "true"`); quoted numeric and boolean scalars now fail validation instead of silently converting. - **Breaking** — Sourcepoint browser module inclusion now requires explicit `[integrations.sourcepoint].enabled = true`; operators relying on the previous unconditional Sourcepoint module should enable the integration before upgrading. +- **Breaking** — Auction creative sanitization is now opt-in: the new `[auction].sanitize_creatives` defaults to `false` because unconditional sanitization blanked script-based creatives (the majority of programmatic display) while recording normal impressions. `[auction].rewrite_creatives` keeps its `true` default. The 1 MiB cap is now enforced on rewritten output as well as raw input and in every processing mode, rewriting fails closed on parser errors instead of emitting partial output, and `hb_cache_host`/`hb_cache_path` are emitted only for bids that supplied no creative — any bid carrying its own `adm` ships without them, so a processed or rejected creative can never be re-fetched raw from PBS Cache. Creative markup with no `` token now receives the click-guard runtime, and bidder `` elements are stripped whenever rewriting is enabled. The creative iframe sandbox no longer grants `allow-same-origin`, restoring origin isolation; rewritten-click recovery from the resulting opaque-origin iframe uses the GET `/first-party/proxy-rebuild` navigation fallback, now registered in every adapter, and dynamic resource signing inside those iframes is disabled pending [#982](https://github.com/IABTechLab/trusted-server/issues/982). Upgrading: binaries that predate `sanitize_creatives` reject a blob carrying it, so upgrade the binary first, then push the config. Rollback: non-default values (`sanitize_creatives = true`, `rewrite_creatives = false`) are serialized into the config blob and older binaries reject unknown fields — before rolling back to a binary that predates a field, restore its default, push the default-compatible blob, then roll back. - The SPA re-auction endpoint moved from `/__ts/page-bids` to `/_ts/page-bids`, joining every other internal route in the `/_ts/` namespace. The old path stays registered as a deprecated alias so already-loaded bundles keep serving ads, and responses on it carry a `Link: …; rel="deprecation"` header so remaining traffic is measurable from edge logs; removal is tracked in [#970](https://github.com/IABTechLab/trusted-server/issues/970). Two deployment notes: audit `[[handlers]]` for patterns broad enough to cover `/_ts` (for example `^/_ts`), which would put this browser-facing endpoint behind Basic Auth and return `401` to every visitor — scope them to `^/_ts/admin`; and prefer rolling forward over rolling back, since a server reverted past this release does not register the canonical path. In both cases the shipped client falls back to the deprecated alias, so the exposure is bounded until that alias is removed. +- Added optional APS `inventory_domain` and `inventory_page_origin` overrides for deployments whose edge hostname differs from the APS-authorized inventory identity. +- Preserved APS renderer capabilities through the client-side `trustedServer` Prebid adapter, allowing its generated `hb_adid` to render through GAM and Prebid Universal Creative instead of producing an empty creative. ### Security @@ -24,7 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the default-true `[auction].rewrite_creatives` option. Setting it to `false` preserves mandatory creative sanitization across `POST /auction` and publisher SSAT/page-bids delivery while skipping first-party resource/click URL rewriting; it also skips creative TSJS injection on `POST /auction`. +- Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape. +- Added typed APS renderer transport for direct auctions and GAM/Prebid Universal Creative, using a minimized one-bid envelope, a fragment-bound nonce, and an opaque sandboxed renderer endpoint. +- Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery. +- `creative_opportunities.slot.gam_unit_path` is now a template supporting `{network_id}`, `{slot_id}`, and `{section}`, so a publisher whose ad unit varies by site section expresses it in one slot rule instead of one per (slot × section). `{section}` derives from the request path: `[creative_opportunities].section_segment` selects which path segment names the section (0-based, default `0`; set `1` for locale-prefixed URLs), and `section_root` supplies the value for paths with no such segment. `section_root` is required when a template uses `{section}`. Existing static and absent `gam_unit_path` configs are unchanged. Startup now also rejects a blank `gam_network_id` when slots are configured. Note that a config setting `section_root` or `section_segment` requires a binary that knows those keys — rolling the binary back below this release while the keys are present fails config load; configs that omit them roll back cleanly. - Added Osano consent mirror integration docs and public enablement guidance. - Implemented basic authentication for configurable endpoint paths (#73) - Added integrations guide with example `testlight` integration diff --git a/TESTING.md b/TESTING.md index e1ac1c3a2..e5ccba4cf 100644 --- a/TESTING.md +++ b/TESTING.md @@ -46,8 +46,8 @@ curl -X POST http://localhost:7676/auction \ **With Orchestrator Enabled** (`auction.enabled = true`): - Logs showing: `"Using auction orchestrator"` -- Parallel execution of APS (mocked) and Prebid (real) -- GAM mediation (mocked) selecting winning bids +- Parallel execution of APS OpenRTB and Prebid Server +- Optional mock-adserver mediation selecting winning bids - Final response with winning creatives **With Orchestrator Disabled** (`auction.enabled = false`): @@ -66,11 +66,13 @@ providers = ["prebid", "aps"] mediator = "adserver_mock" # If set: mediation, if omitted: highest bid wins timeout_ms = 2000 -# Mock provider configs +# APS OpenRTB provider. The built-in production endpoint is used when +# endpoint is omitted; use only an account authorized for test traffic. [integrations.aps] enabled = true -mock = true -mock_price = 2.50 +account_id = "example-account" +timeout_ms = 800 +debug = false [integrations.adserver_mock] enabled = true @@ -90,10 +92,10 @@ mediator = "adserver_mock" # Mediator configured = parallel mediation strategy ``` **Expected Flow:** -1. Prebid queries real SSPs -2. APS returns mock bids ($2.50 CPM) -3. AdServer Mock mediates between all bids -4. Winning creative returned +1. Prebid queries its configured bidders through Prebid Server +2. APS sends an OpenRTB request for eligible banner impressions +3. AdServer Mock mediates the provider responses +4. The winning creative or typed APS renderer is returned ### Scenario 2: Parallel Only (No Mediation) **Config:** @@ -131,8 +133,9 @@ INFO: Running 2 bidders in parallel INFO: Requesting bids from: prebid INFO: Prebid returned 2 bids (time: 120ms) INFO: Requesting bids from: aps -INFO: APS (MOCK): returning 2 bids in 80ms -INFO: GAM mediation: slot 'header-banner' won by 'amazon-aps' at $2.50 CPM +INFO: APS requests bids for 2 impressions +INFO: APS returns 2 accepted bids in 80ms +INFO: GAM mediation: slot 'header-banner' won by 'aps' at $2.50 CPM ``` ### Verify Provider Registration @@ -156,18 +159,19 @@ INFO: Registering auction provider: adserver_mock ## Next Steps -1. **Test with real Prebid Server** - Verify Prebid bids work correctly -2. **Implement real APS** - Replace mock with actual Amazon TAM API calls -3. **Implement real GAM** - Add Google Ad Manager API integration -4. **Add metrics** - Track bid rates, win rates, latency per provider +1. **Verify Prebid Server demand** - Confirm configured bidders return expected test bids +2. **Verify APS eligibility** - Confirm the test account, inventory identity, and `/e/pb/bid` endpoint are authorized +3. **Exercise renderer security** - Run the APS browser integration suite for iframe and script creatives +4. **Add metrics** - Track bid rates, win rates, latency, and aggregate drop reasons per provider -## Mock Provider Behavior +## Provider Behavior ### APS (Amazon) -- Returns bids for all slots -- Default mock price: $2.50 CPM -- Always returns 2 bids -- Response time: ~80ms (simulated) +- Sends real OpenRTB requests for eligible banner slots +- Safely drops malformed, unsupported, or unrenderable bids and reports aggregate reasons +- Reduces multiple APS candidates to one winner per impression +- Returns typed renderer descriptors rather than exposing `adm` outside the sandbox +- Automated tests intercept upstream traffic and use fictional response fixtures ### AdServer Mock - Acts as mediator by calling mocktioneer's mediation endpoint diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 613ce3235..85bd2a211 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -29,6 +29,7 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; +use trusted_server_core::trace_cookie::handle_trace_mode; use trusted_server_core::platform::RuntimeServices; @@ -262,6 +263,7 @@ enum NamedRouteHandler { /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -286,7 +288,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 13] { +fn named_routes() -> [NamedRoute; 14] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -327,6 +329,11 @@ fn named_routes() -> [NamedRoute; 13] { primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::LegacyAdminDenied, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -364,7 +371,11 @@ fn named_routes() -> [NamedRoute; 13] { }, NamedRoute { path: "/first-party/proxy-rebuild", - primary_methods: &[Method::POST], + // GET serves the click guard's navigation fallback: the creative + // iframe is an opaque origin (sandbox without `allow-same-origin`), + // so its JSON POST is blocked by CORS and the guard navigates here + // for a 302 instead. + primary_methods: &[Method::GET, Method::POST], handler: NamedRouteHandler::FirstPartyProxyRebuild, }, ] @@ -404,6 +415,9 @@ fn named_route_handler( Ok(resp) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), + NamedRouteHandler::TraceMode => { + handle_trace_mode(&state.settings, req.uri().query()) + } NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 4b15b4c6a..03caa3d11 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -91,6 +91,7 @@ fn all_explicit_routes_are_registered() { ("GET", "/first-party/click"), ("GET", "/first-party/sign"), ("POST", "/first-party/sign"), + ("GET", "/first-party/proxy-rebuild"), ("POST", "/first-party/proxy-rebuild"), ]; diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 9f2e40796..dc7a7e91f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -29,6 +29,7 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::build_runtime_services; @@ -474,6 +475,15 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async { Ok::(admin_key_management_not_supported()) }) + // Render-trace toggle: arms/disarms the ts-trace cookie and + // redirects to `/`. Gated by [debug] trace_route_enabled (404 when + // off). + .get( + "/_ts/trace", + make_handler(Arc::clone(&state), |s, _services, req| async move { + handle_trace_mode(&s.settings, req.uri().query()) + }), + ) .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { @@ -517,6 +527,16 @@ fn build_router(state: &Arc) -> RouterService { handle_first_party_proxy_sign(&s.settings, &services, req).await }), ) + // GET serves the click guard's navigation fallback: the creative + // iframe is an opaque origin (sandbox without `allow-same-origin`), + // so its JSON POST is blocked by CORS and the guard navigates here + // for a 302 instead. + .get( + "/first-party/proxy-rebuild", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_first_party_proxy_rebuild(&s.settings, &services, req).await + }), + ) .post( "/first-party/proxy-rebuild", make_handler(Arc::clone(&state), |s, services, req| async move { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index d5eb98451..09e3ed324 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -230,6 +230,7 @@ fn all_explicit_routes_are_registered() { ("GET", "/first-party/click"), ("GET", "/first-party/sign"), ("POST", "/first-party/sign"), + ("GET", "/first-party/proxy-rebuild"), ("POST", "/first-party/proxy-rebuild"), ]; diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 5258d3455..8e56916b2 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -26,12 +26,14 @@ //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | //! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] | +//! | GET | `/_ts/trace` | [`handle_trace_mode`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | //! | POST | `/auction` | [`handle_auction`] | //! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | //! | GET | `/first-party/click` | [`handle_first_party_click`] | //! | GET | `/first-party/sign` | [`handle_first_party_proxy_sign`] | //! | POST | `/first-party/sign` | [`handle_first_party_proxy_sign`] | +//! | GET | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | //! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | //! | GET | `/` and `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback | //! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | `/` and `/{*rest}` | integration proxy or publisher fallback | @@ -129,6 +131,7 @@ use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ @@ -595,6 +598,7 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::TraceMode => handle_trace_mode(&state.settings, req.uri().query()), NamedRouteHandler::Auction => { // The auction reads consent data, so the consent KV store must be // available — fail closed with 503 when it is configured but @@ -1007,6 +1011,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -1088,6 +1093,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -1127,7 +1137,10 @@ const NAMED_ROUTES: &[NamedRoute] = &[ }, NamedRoute { path: "/first-party/proxy-rebuild", - primary_methods: &[Method::POST], + // GET serves the click guard's navigation fallback: the creative iframe + // is an opaque origin (sandbox without `allow-same-origin`), so its JSON + // POST is blocked by CORS and the guard navigates here for a 302 instead. + primary_methods: &[Method::GET, Method::POST], handler: NamedRouteHandler::FirstPartyProxyRebuild, }, ]; @@ -1811,6 +1824,55 @@ mod tests { ); } + #[test] + fn dispatch_trace_route_is_disabled_by_default() { + let router = test_router(); + let response = route(&router, empty_request(Method::GET, "/_ts/trace")); + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "disabled trace route should return 404" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "disabled trace route should not set a cookie" + ); + } + + #[test] + fn dispatch_trace_route_arms_cookie_and_redirects() { + let mut settings = test_settings(); + settings.debug.trace_route_enabled = true; + let state = app_state_for_settings(settings); + let router = TrustedServerApp::routes_for_state(&state); + let response = route(&router, empty_request(Method::GET, "/_ts/trace")); + + assert_eq!( + response.status(), + StatusCode::FOUND, + "enabled trace route should redirect to root" + ); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()), + Some("/"), + "trace route should redirect to /" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert!( + set_cookie.starts_with("ts-trace=1;"), + "trace route should arm the ts-trace cookie" + ); + } + #[test] fn dispatch_set_tester_sets_cookie_on_configured_domain() { let mut settings = test_settings(); diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index f2ff5d9e5..db55aa07b 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -328,10 +328,11 @@ impl<'a> BackendConfig<'a> { /// Ensure a dynamic backend exists for this configuration and return its name. /// - /// The name is a collision-resistant function of the complete backend spec - /// (see `Self::compute_name`), so different specs — for example, different - /// timeout values — always produce different backend registrations and a - /// tight deadline cannot be silently widened by an earlier registration. + /// The backend name is derived from the scheme, host, port, certificate + /// setting, `first_byte_timeout`, and `between_bytes_timeout` to avoid + /// collisions. Different timeout values produce different backend + /// registrations so that a tight deadline cannot be silently widened by an + /// earlier registration. /// /// # Errors /// diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 68f2ddde6..4f4ba5133 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -28,6 +28,7 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware}; use crate::platform::build_runtime_services; @@ -142,7 +143,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -150,13 +151,14 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { ("/_ts/admin/keys/deactivate", &[Method::POST]), ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), + ("/_ts/trace", &[Method::GET]), ("/auction", &[Method::POST]), (PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]), (PAGE_BIDS_LEGACY_PATH, &[Method::GET, Method::OPTIONS]), ("/first-party/proxy", &[Method::GET]), ("/first-party/click", &[Method::GET]), ("/first-party/sign", &[Method::GET, Method::POST]), - ("/first-party/proxy-rebuild", &[Method::POST]), + ("/first-party/proxy-rebuild", &[Method::GET, Method::POST]), ] } @@ -551,6 +553,21 @@ fn build_router(state: &Arc) -> RouterService { } }; + // GET /_ts/trace — render-trace toggle: arms/disarms the ts-trace + // cookie and redirects to `/`. Gated by [debug] trace_route_enabled + // (404 when off). + let s = Arc::clone(&state); + let trace_mode_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let req = ctx.into_request(); + Ok::( + handle_trace_mode(&s.settings, req.uri().query()) + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + // GET /_ts/page-bids — SPA re-auction endpoint. let s = Arc::clone(&state); let page_bids_handler = move |ctx: RequestContext| { @@ -626,7 +643,10 @@ fn build_router(state: &Arc) -> RouterService { }; let fp_sign_post_handler = fp_sign_handler.clone(); - // /first-party/proxy-rebuild + // GET + POST /first-party/proxy-rebuild — GET serves the click guard's + // navigation fallback: the creative iframe is an opaque origin (sandbox + // without `allow-same-origin`), so its JSON POST is blocked by CORS and + // the guard navigates here for a 302 instead. let s = Arc::clone(&state); let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); @@ -640,6 +660,7 @@ fn build_router(state: &Arc) -> RouterService { ) } }; + let fp_rebuild_post_handler = fp_rebuild_handler.clone(); // Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher. async fn dispatch( @@ -754,6 +775,7 @@ fn build_router(state: &Arc) -> RouterService { // credentials and key-management payloads to the origin. .post("/_ts/admin/keys/rotate", admin_not_supported_handler) .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) + .get("/_ts/trace", trace_mode_handler) .post("/auction", auction_handler) .get(PAGE_BIDS_PATH, page_bids_handler.clone()) .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) @@ -771,7 +793,8 @@ fn build_router(state: &Arc) -> RouterService { .get("/first-party/click", fp_click_handler) .get("/first-party/sign", fp_sign_handler) .post("/first-party/sign", fp_sign_post_handler) - .post("/first-party/proxy-rebuild", fp_rebuild_handler); + .get("/first-party/proxy-rebuild", fp_rebuild_handler) + .post("/first-party/proxy-rebuild", fp_rebuild_post_handler); for method in LEGACY_ADMIN_DENY_METHODS { builder = builder.route("/admin/keys/rotate", method.clone(), legacy_admin_deny); diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 2e1f0f6e5..2f7b1037e 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -517,6 +517,27 @@ async fn first_party_proxy_rebuild_is_routed() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn first_party_proxy_rebuild_get_is_routed() { + // The opaque-origin creative click guard recovers via GET navigation, so the + // route must be registered for GET and must not fall through to the + // publisher origin. This asserts routing only; the 302 and its rebuilt + // Location are covered by `proxy_rebuild_get_with_origin_form_uri_redirects` + // in the core crate, which can sign a real `tsclick`. + let router = test_router(); + let req = request_builder() + .method("GET") + .uri("/first-party/proxy-rebuild?tsclick=%2Ffirst-party%2Fclick%3Ftsurl%3Dhttps%253A%252F%252Fexample.com") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(router, req).await; + assert_ne!( + resp.status().as_u16(), + 404, + "GET /first-party/proxy-rebuild must be routed" + ); +} + // --------------------------------------------------------------------------- // First-party absolute-URI regression — Spin delivers a path-only request URI // (built from IncomingRequest::path_with_query), so the shared proxy/click/sign diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs index 3a3b9dffd..39345137b 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -28,6 +28,7 @@ ids = ["trusted_server_config"] ids = ["trusted_server_secrets"] "#; const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES"; +const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES"; struct MigratedProject { directory: TempDir, @@ -42,7 +43,11 @@ fn migrated_legacy_project() -> MigratedProject { let mut document = LEGACY_CONFIG .parse::() .expect("should parse legacy integration config"); + // EdgeZero v0.0.4 environment overlays cannot create missing TOML leaves, + // so a migrated config must carry both creative-processing leaves for the + // corresponding environment variables to take effect. document["auction"]["rewrite_creatives"] = value(true); + document["auction"]["sanitize_creatives"] = value(false); fs::write(&config_path, document.to_string()).expect("should write migrated config"); fs::write(&manifest_path, MANIFEST).expect("should write test manifest"); MigratedProject { @@ -107,6 +112,49 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() { ); } +#[test] +fn migrated_legacy_config_applies_sanitize_creatives_environment_override() { + let project = migrated_legacy_project(); + let output = Command::new(env!("CARGO_BIN_EXE_ts")) + .args(["config", "push", "--adapter", "axum", "--manifest"]) + .arg(&project.manifest_path) + .arg("--app-config") + .arg(&project.config_path) + .args(["--yes", "--no-diff"]) + .current_dir(project.directory.path()) + .env(SANITIZE_ENV, "true") + .output() + .expect("should run ts config push"); + + assert!( + output.status.success(), + "valid boolean overlay should push successfully: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let local_store_path = project + .directory + .path() + .join(".edgezero/local-config-trusted_server_config.json"); + let local_store: serde_json::Value = serde_json::from_str( + &fs::read_to_string(local_store_path).expect("should read pushed local config"), + ) + .expect("should parse local config store"); + let envelope_json = local_store + .as_object() + .and_then(|entries| entries.values().next()) + .and_then(serde_json::Value::as_str) + .expect("should contain a blob envelope"); + let envelope: serde_json::Value = + serde_json::from_str(envelope_json).expect("should parse blob envelope"); + + assert_eq!( + envelope["data"]["auction"]["sanitize_creatives"], + serde_json::Value::Bool(true), + "pushed config should contain the sanitize environment override" + ); +} + #[test] fn migrated_legacy_config_default_rewrite_creatives_has_no_local_diff() { let project = migrated_legacy_project(); diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 96eec2f1f..7c1303dd4 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -13,6 +13,7 @@ fn make_config() -> HtmlProcessorConfig { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + suppress_datadome_client_side_tag: false, } } diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index 1ee2c66f8..69c19475b 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -117,7 +117,7 @@ When a request arrives at the `/auction` endpoint, it goes through the following ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 9. Each Provider Processes Request │ -│ - Transform AuctionRequest → Provider format (e.g., APS TAM) │ +│ - Transform AuctionRequest → Provider OpenRTB request │ │ - Send HTTP request to provider endpoint │ │ - Parse provider response │ │ - Transform → AuctionResponse with Bid[] │ @@ -136,7 +136,7 @@ When a request arrives at the `/auction` endpoint, it goes through the following ┌──────────────────────────────────────────────────────────────────────┐ │ 11. Transform to OpenRTB Response (mod.rs:274-322) │ │ - Build seatbid array (one per winning bid) │ -│ - Always sanitize creative HTML │ +│ - Sanitize creative HTML when enabled (opt-in) │ │ - Rewrite creative HTML when enabled (default) │ │ - Add orchestrator metadata (timing, strategy, bid count) │ └──────────────────────────────────────────────────────────────────────┘ @@ -189,30 +189,21 @@ AdSlot { #### 3. Provider Execution Each registered provider (APS, Prebid, etc.) receives the `AuctionRequest` and: -- Transforms it to their specific format (e.g., APS TAM, OpenRTB) +- Transforms it to the provider's OpenRTB request format - Makes HTTP request to their endpoint - Parses the response - Returns `AuctionResponse` with `Bid[]` For example, APS provider: ```rust -// Transform AuctionRequest → ApsBidRequest -let aps_request = ApsBidRequest { - pub_id: "5128", - slots: vec![ - ApsSlot { - slot_id: "header-banner", - sizes: vec![[728, 90], [970, 250]], - slot_name: Some("header-banner"), - } - ], - page_url: Some("https://example.com"), - ua: Some("Mozilla/5.0..."), - timeout: Some(800), -}; - -// HTTP POST to http://localhost:6767/e/dtb/bid -// Parse response → AuctionResponse +// Transform AuctionRequest → APS OpenRTB request +// - ext.account = configured account_id +// - ext.sdk = { source: "prebid", version: "2.2.0" } +// - banner slots become secure impressions with matching formats/floors +// - existing consent, identity, device, and geo privacy gates apply + +// HTTP POST to https://aps.example.com/e/pb/bid +// Parse decoded-price response → AuctionResponse with a typed renderer ``` #### 4. Response Assembly @@ -223,17 +214,29 @@ The orchestrator collects all bids and creates an OpenRTB response: "id": "auction-response", "seatbid": [ { - "seat": "amazon-aps", + "seat": "aps", "bid": [ { - "id": "amazon-aps-header-banner", + "id": "fictional-selected-bid-id", "impid": "header-banner", "price": 2.5, - "adm": "