From 5a4ef23ec67dea7ce6c9247af219257662a163d9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 27 Jul 2026 13:52:21 +0530 Subject: [PATCH 1/2] Rename /__ts/page-bids to /_ts/page-bids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPA re-auction endpoint was the only internal route using a double-underscore prefix; every other internal path lives under /_ts/. Move it to /_ts/page-bids and switch the tsjs client fetch to match. A browser runs whichever tsjs bundle it was already served, so pages loaded before the rename — and cached bundles — keep requesting the old path. On a SPA that path delivers ads for in-session navigations, so /__ts/page-bids stays registered to the same handler as a transition alias until those bundles age out. Both paths are defined once in core as PAGE_BIDS_PATH and PAGE_BIDS_LEGACY_PATH so removal touches one const plus its four registrations. handle_page_bids logs an info line when the alias serves a gate-passed request. Without it nothing in the app distinguishes the two paths, so the "no remaining legacy traffic" precondition for removing the alias would only be answerable from edge access logs. Route coverage was missing for the page-bids GET registrations on Cloudflare and Spin, where GET and OPTIONS are registered separately and the preflight-denial parity test does not imply the GET side is wired. Mutation testing confirmed a broken registration previously passed every suite. The route-table assertions pin literal paths rather than the consts, since looking a route up by the same const it was registered with still passes when the const's value changes. --- crates/trusted-server-adapter-axum/src/app.rs | 18 +++- .../tests/routes.rs | 10 ++ .../src/app.rs | 56 ++++++----- .../tests/routes.rs | 10 ++ .../trusted-server-adapter-fastly/src/app.rs | 62 +++++++++++- crates/trusted-server-adapter-spin/src/app.rs | 26 +++-- .../tests/routes.rs | 50 ++++++++++ .../src/auction/endpoints.rs | 8 +- .../src/auction/orchestrator.rs | 4 +- .../src/auction/telemetry.rs | 2 +- .../src/integrations/gpt.rs | 2 +- crates/trusted-server-core/src/publisher.rs | 95 +++++++++++++++++-- .../tests/parity.rs | 38 ++++---- .../lib/src/integrations/gpt/index.ts | 4 +- .../test/integrations/gpt/spa_hook.test.ts | 6 +- 15 files changed, 311 insertions(+), 80 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f4329574..d4ec91047 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -19,8 +19,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -140,7 +140,7 @@ where // --------------------------------------------------------------------------- /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, -/// `/__ts/page-bids`, and the publisher fallback). +/// `/_ts/page-bids`, and the publisher fallback). /// /// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction /// Unknown, which fails the auction consent gate closed even for consented @@ -279,7 +279,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 12] { +fn named_routes() -> [NamedRoute; 13] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -328,7 +328,15 @@ fn named_routes() -> [NamedRoute; 12] { // GET runs the SPA re-auction; OPTIONS is denied in-handler as a CORS // preflight guard for this side-effecting endpoint. NamedRoute { - path: "/__ts/page-bids", + path: PAGE_BIDS_PATH, + primary_methods: &[Method::GET, Method::OPTIONS], + handler: NamedRouteHandler::PageBids, + }, + // Deprecated double-underscore alias, kept so tsjs bundles served before + // the `/_ts/page-bids` rename keep getting ads on SPA navigations until + // they age out of browser caches. See `PAGE_BIDS_LEGACY_PATH`. + NamedRoute { + path: PAGE_BIDS_LEGACY_PATH, primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c4bf7d990..4b15b4c6a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -77,6 +77,16 @@ fn all_explicit_routes_are_registered() { ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), + // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both + // paths are spelled out as literals rather than referencing + // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the + // actual URL the tsjs client fetches — asserting a const against itself + // would still pass if the const's value changed out from under the + // client. + ("GET", "/_ts/page-bids"), + ("OPTIONS", "/_ts/page-bids"), + ("GET", "/__ts/page-bids"), + ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f6..b3694fa5a 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -21,8 +21,9 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PublisherResponse, buffer_publisher_response_async, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -125,7 +126,7 @@ fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, -/// `/__ts/page-bids`, and the publisher fallback). +/// `/_ts/page-bids`, and the publisher fallback). /// /// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction /// Unknown, which fails the auction consent gate closed even for consented @@ -480,28 +481,6 @@ fn build_router(state: &Arc) -> RouterService { .await }), ) - // SPA re-auction endpoint. The OPTIONS preflight for this - // side-effecting GET is denied so the GET handler's `X-TSJS-Page-Bids` - // gate stays trustworthy. - .route( - "/__ts/page-bids", - Method::OPTIONS, - make_handler(Arc::clone(&state), |_s, _services, _req| async move { - Ok(page_bids_preflight_denied()) - }), - ) - .get( - "/__ts/page-bids", - make_handler(Arc::clone(&state), |s, services, req| async move { - let ec_context = build_ec_context(&s.settings, &services, &req); - let auction = AuctionDispatch { - orchestrator: &s.orchestrator, - slots: s.settings.creative_opportunity_slots(), - registry: None, - }; - handle_page_bids(&s.settings, &services, None, auction, &ec_context, req).await - }), - ) .get( "/first-party/proxy", make_handler(Arc::clone(&state), |s, services, req| async move { @@ -533,6 +512,33 @@ fn build_router(state: &Arc) -> RouterService { }), ); + // SPA re-auction endpoint, registered on the canonical path and on the + // deprecated `PAGE_BIDS_LEGACY_PATH` double-underscore alias. The alias + // keeps tsjs bundles served before the `/_ts/page-bids` rename getting + // ads on SPA navigations until they age out of browser caches. + // + // The OPTIONS preflight is denied on both so the GET handler's + // `X-TSJS-Page-Bids` gate stays trustworthy — an alias that let the + // preflight fall through to a permissive origin would reopen exactly + // the cross-site hole the canonical path closes. + let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { + let ec_context = build_ec_context(&s.settings, &services, &req); + let auction = AuctionDispatch { + orchestrator: &s.orchestrator, + slots: s.settings.creative_opportunity_slots(), + registry: None, + }; + handle_page_bids(&s.settings, &services, None, auction, &ec_context, req).await + }); + let page_bids_preflight = + make_handler(Arc::clone(&state), |_s, _services, _req| async move { + Ok(page_bids_preflight_denied()) + }); + for path in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { + router = router.route(path, Method::GET, page_bids.clone()); + router = router.route(path, Method::OPTIONS, page_bids_preflight.clone()); + } + let legacy_admin_deny = make_handler(Arc::clone(&state), |_s, _services, _req| async move { Ok(legacy_admin_alias_denied()) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index df2781945..d5eb98451 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -216,6 +216,16 @@ fn all_explicit_routes_are_registered() { ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("POST", "/auction"), + // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both + // paths are spelled out as literals rather than referencing + // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the + // actual URL the tsjs client fetches — asserting a const against itself + // would still pass if the const's value changed out from under the + // client. + ("GET", "/_ts/page-bids"), + ("OPTIONS", "/_ts/page-bids"), + ("GET", "/__ts/page-bids"), + ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 955ff235b..8b7de4000 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -116,8 +116,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -1083,7 +1083,17 @@ const NAMED_ROUTES: &[NamedRoute] = &[ // GET runs the SPA re-auction; OPTIONS is denied in-handler as a CORS // preflight guard for this side-effecting endpoint. NamedRoute { - path: "/__ts/page-bids", + path: PAGE_BIDS_PATH, + primary_methods: &[Method::GET, Method::OPTIONS], + handler: NamedRouteHandler::PageBids, + }, + // Deprecated double-underscore alias. tsjs bundles served before the + // `/_ts/page-bids` rename keep requesting this path from already-loaded + // pages and browser caches; dropping it would strand SPA navigations + // without ads until those bundles age out. See `PAGE_BIDS_LEGACY_PATH`; + // removal is tracked by IABTechLab/trusted-server#970. + NamedRoute { + path: PAGE_BIDS_LEGACY_PATH, primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, @@ -1211,8 +1221,8 @@ mod tests { use std::sync::Arc; use super::{ - AppState, NAMED_ROUTES, NamedRouteHandler, TrustedServerApp, build_state_from_settings, - startup_error_router, + AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, + TrustedServerApp, build_state_from_settings, startup_error_router, }; use bytes::Bytes; use edgezero_core::body::Body; @@ -1624,6 +1634,48 @@ mod tests { } } + #[test] + fn page_bids_serves_canonical_path_and_deprecated_alias() { + // The SPA re-auction endpoint lives at the canonical single-underscore + // `/_ts/page-bids`, matching every other internal route. The deprecated + // `/__ts/page-bids` alias must stay registered to the same handler with + // the same methods until pre-rename tsjs bundles age out of browser + // caches — dropping it would leave those clients without ads on SPA + // navigations. + // + // The paths are literals, not `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`. + // Looking a route up by the same const it was registered with is + // tautological: it keeps passing if the const's value changes, which is + // exactly the break that would silently desync the server from the tsjs + // client's hardcoded fetch path. Pin the consts to their literals too so + // a rename has to be deliberate. + assert_eq!( + PAGE_BIDS_PATH, "/_ts/page-bids", + "canonical page-bids path must match the path tsjs fetches" + ); + assert_eq!( + PAGE_BIDS_LEGACY_PATH, "/__ts/page-bids", + "legacy alias must match the path pre-rename tsjs bundles fetch" + ); + + for path in ["/_ts/page-bids", "/__ts/page-bids"] { + let route = NAMED_ROUTES + .iter() + .find(|route| route.path == path) + .unwrap_or_else(|| panic!("{path} should be registered")); + + assert!( + matches!(route.handler, NamedRouteHandler::PageBids), + "{path} must map to the page-bids handler" + ); + assert_eq!( + route.primary_methods, + &[Method::GET, Method::OPTIONS], + "{path} must handle GET and OPTIONS directly, not fall through to the publisher" + ); + } + } + #[test] fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { // Regression for the credential-leak finding: with a production-shaped diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce74..9f9d3235f 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -20,8 +20,9 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PublisherResponse, buffer_publisher_response_async, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -141,7 +142,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -150,7 +151,8 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), ("/auction", &[Method::POST]), - ("/__ts/page-bids", &[Method::GET, Method::OPTIONS]), + (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]), @@ -322,7 +324,7 @@ fn health_response() -> Response { } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, -/// `/__ts/page-bids`, and the publisher fallback). +/// `/_ts/page-bids`, and the publisher fallback). /// /// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction /// Unknown, which fails the auction consent gate closed even for consented @@ -541,7 +543,7 @@ fn build_router(state: &Arc) -> RouterService { } }; - // GET /__ts/page-bids — SPA re-auction endpoint. + // GET /_ts/page-bids — SPA re-auction endpoint. let s = Arc::clone(&state); let page_bids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); @@ -562,7 +564,7 @@ fn build_router(state: &Arc) -> RouterService { } }; - // OPTIONS /__ts/page-bids — deny the CORS preflight for this + // OPTIONS /_ts/page-bids — deny the CORS preflight for this // side-effecting GET so the `X-TSJS-Page-Bids` gate stays trustworthy. let page_bids_options_handler = |_ctx: RequestContext| async { Ok::(page_bids_preflight_denied()) @@ -731,9 +733,15 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/rotate", admin_not_supported_handler) .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) .post("/auction", auction_handler) - .get("/__ts/page-bids", page_bids_handler) + .get(PAGE_BIDS_PATH, page_bids_handler.clone()) + .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) + // Deprecated double-underscore alias, kept so tsjs bundles served + // before the `/_ts/page-bids` rename keep getting ads on SPA + // navigations until they age out of browser caches. See + // `PAGE_BIDS_LEGACY_PATH`. + .get(PAGE_BIDS_LEGACY_PATH, page_bids_handler) .route( - "/__ts/page-bids", + PAGE_BIDS_LEGACY_PATH, Method::OPTIONS, page_bids_options_handler, ) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 9b96dbd70..2e1f0f6e5 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -330,6 +330,56 @@ async fn auction_is_routed() { assert_ne!(resp.status().as_u16(), 404, "/auction must be routed"); } +/// `GET` on the SPA re-auction endpoint must reach the page-bids handler on +/// both the canonical path and its deprecated `/__ts/` alias. +/// +/// The alias is what pre-rename tsjs bundles still request, and on a SPA that +/// path is what delivers ads for in-session navigations — so a dropped or +/// misspelled registration silently costs revenue rather than erroring loudly. +/// Spin registers `GET` and `OPTIONS` separately, so the preflight-denial parity +/// test does not imply the `GET` side is wired. +/// +/// Paths are literals rather than `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`: +/// this pins the actual URL the client fetches, which asserting a const against +/// itself would not. +/// +/// These test settings configure no creative opportunities, so the handler's own +/// deterministic answer is a 404 `Creative opportunities not configured`. That +/// body is the anchor: an unregistered path would instead fall through to the +/// publisher fallback and attempt an outbound fetch to the (nonexistent) test +/// origin, which cannot produce this message. A bare `!= 404` check would be +/// wrong here — the handler legitimately returns 404 under this config. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn page_bids_get_is_routed_on_canonical_path_and_alias() { + let mut responses = Vec::new(); + + for path in ["/_ts/page-bids", "/__ts/page-bids"] { + let req = request_builder() + .method("GET") + .uri(path) + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + let status = resp.status().as_u16(); + let body = String::from_utf8_lossy(&resp.into_body().into_bytes().unwrap_or_default()) + .into_owned(); + + assert!( + body.contains("Creative opportunities not configured"), + "GET {path} must reach the page-bids handler, \ + got status {status} body {body:?}" + ); + + responses.push((status, body)); + } + + assert_eq!( + responses[0], responses[1], + "the deprecated alias must answer identically to the canonical path" + ); +} + // --------------------------------------------------------------------------- // Publisher fallback method parity — non-GET/POST methods must reach the // publisher origin fallback (not a router-level 405), matching Fastly/Axum. diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 1b0ced7a7..1d959c05d 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -86,7 +86,7 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// callers** (e.g. slim-Prebid, native apps, server-to-server integrations). /// It is **not** the intended path for scroll or GPT refresh events. /// -/// **SPA navigation** is handled by `GET /__ts/page-bids`: the client-side SPA +/// **SPA navigation** is handled by `GET /_ts/page-bids`: the client-side SPA /// hook (`installSpaAuctionHook`) intercepts `pushState`/`replaceState`/`popstate` /// events and calls that endpoint to fetch fresh slots and bids for each new /// route, then invokes `window.tsjs.adInit()` with the updated data. @@ -171,7 +171,7 @@ pub async fn handle_auction( let consent_context = ec_context.consent().clone(); // Server-side auction consent gate. The publisher-navigation and - // `/__ts/page-bids` paths fail closed for GDPR/unknown jurisdictions that + // `/_ts/page-bids` paths fail closed for GDPR/unknown jurisdictions that // lack effective TCF Purpose 1. `/auction` is the programmatic entry point // for the same server-side auction, so it must gate identically: returning // a no-bid response here prevents outbound PBS/APS calls and the forwarding @@ -234,7 +234,7 @@ pub async fn handle_auction( // denied but a non-personalized auction may still run — could forward // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` // only strips on TCF/GDPR signals. This matches the publisher and - // `/__ts/page-bids` paths, which also resolve client EIDs only when + // `/_ts/page-bids` paths, which also resolve client EIDs only when // `ec_id.is_some()`. let client_eids = if ec_id.is_some() { resolve_client_auction_eids( @@ -646,7 +646,7 @@ mod tests { // GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run // a server-side auction. The /auction endpoint must short-circuit to a // no-bid response before dispatching to any provider — matching the - // publisher-navigation and /__ts/page-bids paths. + // publisher-navigation and /_ts/page-bids paths. let settings = create_test_settings(); let config = AuctionConfig { enabled: true, diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index bf9ecad7b..71afa7c50 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -367,7 +367,7 @@ impl AuctionOrchestrator { // restore nurl/burl/ad_id and PBS cache fields from the collected SSP // responses. The dispatched collect path already does this; the // synchronous mediation path used by POST /auction and - // /__ts/page-bids must match or mediated cache bids lose the metadata + // /_ts/page-bids must match or mediated cache bids lose the metadata // needed for creative rendering and win/billing beacons. let mediator_resp = mediator .parse_response_with_context( @@ -1779,7 +1779,7 @@ mod tests { // run_parallel_mediation must parse the mediator response via // parse_response_with_context so cache/nurl fields restored from SSP // responses survive the synchronous mediation path (POST /auction, - // /__ts/page-bids), matching the dispatched collect path. + // /_ts/page-bids), matching the dispatched collect path. let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); // bidder send_async stub.push_response(200, b"{}".to_vec()); // mediator send_async diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index d63445369..02752c6f9 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -25,7 +25,7 @@ const DYNAMIC_SEGMENT_REPLACEMENT: &str = ":id"; pub enum AuctionSource { /// Initial publisher navigation using server-side ad templates. InitialNavigation, - /// SPA navigation through `GET /__ts/page-bids`. + /// SPA navigation through `GET /_ts/page-bids`. SpaNavigation, /// Explicit `POST /auction` API. AuctionApi, diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index e21058a21..7783332d9 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -483,7 +483,7 @@ impl IntegrationHeadInjector for GptIntegration { /// for GPT refresh events, runs client-side auctions, and sets targeting for /// subsequent impressions. SPA navigation is handled separately by /// `installSpaAuctionHook()` in the GPT bundle, which re-runs the server-side - /// auction via `GET /__ts/page-bids` on pushState / replaceState / popstate + /// auction via `GET /_ts/page-bids` on pushState / replaceState / popstate /// route changes (see `auction/endpoints.rs`). /// The `POST /auction` endpoint is not involved in scroll or refresh flows. fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4d6e9d2ee..82767e323 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2276,7 +2276,27 @@ fn is_supported_content_encoding(encoding: &str) -> bool { matches!(encoding, "" | "identity" | "gzip" | "deflate" | "br") } -/// Same-origin gate for `/__ts/page-bids`. +/// Canonical URL path of the SPA re-auction endpoint. +/// +/// Lives in the internal `/_ts/` namespace shared by every other Trusted +/// Server route. Adapters register this path; the tsjs SPA hook fetches it. +pub const PAGE_BIDS_PATH: &str = "/_ts/page-bids"; + +/// Deprecated double-underscore alias of [`PAGE_BIDS_PATH`]. +/// +/// The endpoint originally shipped as `/__ts/page-bids`, the only internal path +/// using a `__` prefix. Renaming it is atomic on the server, but a browser runs +/// whichever tsjs bundle it was already served: pages loaded before the rename — +/// and cached bundles — keep requesting this path, and on a SPA that path is what +/// delivers ads for in-session navigations. Adapters route it to the same handler +/// so those clients keep working. +/// +/// Removal is tracked by IABTechLab/trusted-server#970: drop this const and its +/// four adapter registrations once access logs show no remaining traffic on the +/// legacy path. +pub const PAGE_BIDS_LEGACY_PATH: &str = "/__ts/page-bids"; + +/// Same-origin gate for `/_ts/page-bids`. /// /// The endpoint is a side-effecting GET: it dispatches real PBS/APS auctions /// and forwards request-derived signals (IP, UA, geo, consent) to partners. @@ -2306,7 +2326,7 @@ fn page_bids_request_allowed(req: &Request) -> bool { } /// Builds the `403 Forbidden` returned when the side-effecting -/// `/__ts/page-bids` endpoint refuses a request — both the CORS preflight +/// `/_ts/page-bids` endpoint refuses a request — both the CORS preflight /// (`OPTIONS`) and the GET cross-site gate ([`page_bids_request_allowed`]) /// return this single denial shape. /// @@ -2315,7 +2335,8 @@ fn page_bids_request_allowed(req: &Request) -> bool { /// preflight; letting `OPTIONS` fall through to the publisher origin (which may /// return permissive CORS) would defeat that, allowing a cross-site page to /// trigger real PBS/APS auctions from a visitor's browser. Every adapter returns -/// this same response for `OPTIONS /__ts/page-bids`. +/// this same response for `OPTIONS /_ts/page-bids` and for its deprecated +/// `/__ts/page-bids` alias. pub fn page_bids_preflight_denied() -> Response { let mut response = Response::new(EdgeBody::from("Forbidden")); *response.status_mut() = StatusCode::FORBIDDEN; @@ -2340,7 +2361,7 @@ fn normalize_page_bids_path(raw: &str) -> String { } } -/// Handle `GET /__ts/page-bids?path=` — server-side auction for SPA navigation. +/// Handle `GET /_ts/page-bids?path=` — server-side auction for SPA navigation. /// /// Matches creative opportunity slots for the given path, runs a server-side /// auction (APS + PBS), and returns the slot definitions and winning bids as JSON. @@ -2380,6 +2401,22 @@ pub async fn handle_page_bids( return Ok(page_bids_preflight_denied()); } + // Deprecation signal for the transition alias. Logged only after the + // cross-site gate passes, so the count reflects genuine SPA clients still + // running a pre-rename tsjs bundle rather than anything a third-party page + // can inflate. This is the only in-app signal that + // `PAGE_BIDS_LEGACY_PATH` is still in use — the removal precondition in + // IABTechLab/trusted-server#970 is "no remaining traffic on the legacy + // path", which is otherwise only answerable from edge access logs. The line + // is self-limiting: it goes silent as old bundles age out, which is exactly + // the condition being waited on. + if req.uri().path() == PAGE_BIDS_LEGACY_PATH { + log::info!( + "page-bids: served deprecated alias {PAGE_BIDS_LEGACY_PATH} \ + (pre-rename tsjs bundle); see IABTechLab/trusted-server#970" + ); + } + let path_param = req .uri() .query() @@ -4947,11 +4984,15 @@ mod tests { } fn make_page_bids_request(path: &str) -> Request { + make_page_bids_request_on(PAGE_BIDS_PATH, path) + } + + /// Builds a page-bids request against an explicit endpoint path, so the + /// canonical route and its deprecated alias can be compared directly. + fn make_page_bids_request_on(endpoint: &str, path: &str) -> Request { let mut req = Request::builder() .method(Method::GET) - .uri(format!( - "https://test-publisher.com/_ts/page-bids?path={path}" - )) + .uri(format!("https://test-publisher.com{endpoint}?path={path}")) .body(EdgeBody::empty()) .expect("should build test request"); // Pass the same-origin gate the way a browser fetch from the @@ -5002,6 +5043,46 @@ mod tests { .expect("should return ok response") } + /// The deprecated `/__ts/page-bids` alias must be handled identically to + /// the canonical path — same status, same JSON body. + /// + /// The alias exists so pre-rename tsjs bundles keep getting ads on SPA + /// navigations. If the handler ever varied its output by request path + /// (slot matching reads the `path` *query parameter*, not the endpoint + /// path), those clients would silently get different results from the + /// ones on the canonical route. + #[tokio::test] + async fn deprecated_alias_response_matches_canonical_path() { + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + + let canonical = run_page_bids_response( + &settings, + &orchestrator, + &article_slot(), + make_page_bids_request_on(PAGE_BIDS_PATH, "/2024/01/my-article/"), + ) + .await; + let alias = run_page_bids_response( + &settings, + &orchestrator, + &article_slot(), + make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), + ) + .await; + + assert_eq!( + canonical.status(), + alias.status(), + "alias must return the same status as the canonical path" + ); + assert_eq!( + canonical.into_body().into_bytes(), + alias.into_body().into_bytes(), + "alias must return the same body as the canonical path" + ); + } + #[tokio::test] async fn cross_site_fetch_metadata_is_rejected() { let settings = settings_with_co(); diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index e85b1d8d1..acf7f5f4b 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -696,28 +696,34 @@ async fn auction_not_challenged_by_auth_parity() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn page_bids_options_preflight_denied_parity() { - // OPTIONS /__ts/page-bids is a CORS preflight to a side-effecting endpoint. + // OPTIONS /_ts/page-bids is a CORS preflight to a side-effecting endpoint. // Every adapter must refuse it with 403 rather than proxy it to the origin: // a permissive origin preflight would let a cross-site page defeat the GET // handler's `X-TSJS-Page-Bids` gate and trigger real auctions in a visitor's // browser. The denial is unconditional (independent of creative-opportunity // configuration), so all adapters must agree on 403. - let (axum_status, _) = axum_options("/__ts/page-bids").await; - let (cf_status, _) = cf_options("/__ts/page-bids").await; - let (spin_status, _) = spin_options("/__ts/page-bids").await; + // + // The deprecated `/__ts/page-bids` alias routes to the same handler, so it + // must deny the preflight identically — an alias that fell through to the + // origin would reopen the hole the canonical path closes. + for path in ["/_ts/page-bids", "/__ts/page-bids"] { + let (axum_status, _) = axum_options(path).await; + let (cf_status, _) = cf_options(path).await; + let (spin_status, _) = spin_options(path).await; - assert_eq!( - axum_status, 403, - "Axum OPTIONS /__ts/page-bids must be denied with 403, got {axum_status}" - ); - assert_eq!( - cf_status, 403, - "Cloudflare OPTIONS /__ts/page-bids must be denied with 403, got {cf_status}" - ); - assert_eq!( - spin_status, 403, - "Spin OPTIONS /__ts/page-bids must be denied with 403, got {spin_status}" - ); + assert_eq!( + axum_status, 403, + "Axum OPTIONS {path} must be denied with 403, got {axum_status}" + ); + assert_eq!( + cf_status, 403, + "Cloudflare OPTIONS {path} must be denied with 403, got {cf_status}" + ); + assert_eq!( + spin_status, 403, + "Spin OPTIONS {path} must be denied with 403, got {spin_status}" + ); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ca4689684..85e27b506 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -696,7 +696,7 @@ function waitForSlotElements(slots: AuctionSlot[], signal: AbortSignal): Promise * * Patches `history.pushState` and `history.replaceState`, and listens to * `popstate`, so that after each client-side route change the trusted server - * fetches fresh slots + bids from `/__ts/page-bids?path=`, updates + * fetches fresh slots + bids from `/_ts/page-bids?path=`, updates * `window.tsjs.adSlots` / `window.tsjs.bids`, and calls `window.tsjs.adInit()`. * * Idempotent: guarded by `window.tsjs.spaHookInstalled` so multiple calls are safe. @@ -728,7 +728,7 @@ export function installSpaAuctionHook(): void { inflight = controller; try { - const res = await fetch(`/__ts/page-bids?path=${encodeURIComponent(path)}`, { + const res = await fetch(`/_ts/page-bids?path=${encodeURIComponent(path)}`, { credentials: 'include', // Non-simple header doubles as a CSRF token: the server rejects // requests that carry neither same-origin Fetch Metadata nor this diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 9a08defcb..7dc29989c 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -78,7 +78,7 @@ describe('installSpaAuctionHook', () => { await flushAsync(); expect(fetchStub).toHaveBeenCalledWith( - '/__ts/page-bids?path=%2Fnext-page', + '/_ts/page-bids?path=%2Fnext-page', expect.objectContaining({ credentials: 'include', headers: { 'X-TSJS-Page-Bids': '1' }, @@ -223,7 +223,7 @@ describe('installSpaAuctionHook', () => { history.replaceState({}, '', '/replaced'); await flushAsync(); expect(fetchStub).toHaveBeenCalledWith( - '/__ts/page-bids?path=%2Freplaced', + '/_ts/page-bids?path=%2Freplaced', expect.objectContaining({ credentials: 'include' }) ); }); @@ -241,7 +241,7 @@ describe('installSpaAuctionHook', () => { window.dispatchEvent(new PopStateEvent('popstate')); await flushAsync(); expect(fetchStub).toHaveBeenCalledWith( - '/__ts/page-bids?path=%2Fpopped', + '/_ts/page-bids?path=%2Fpopped', expect.objectContaining({ credentials: 'include' }) ); }); From bf4d171b2a2c4aaf089ccb650cfd3e8efc6887d8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 29 Jul 2026 11:42:06 +0530 Subject: [PATCH 2/2] Keep page-bids reachable when the canonical path is unusable Address review findings on the /__ts/page-bids to /_ts/page-bids rename. The rename moved a browser-facing endpoint into the namespace operators protect with [[handlers]] Basic Auth. A pattern broad enough to cover /_ts (rather than ^/_ts/admin) now answers /_ts/page-bids with 401 that no anonymous browser fetch can satisfy, so SPA navigations lose ads. The same failure appears inverted on rollback: a server reverted past the rename does not register the canonical path and proxies it to the publisher origin. The tsjs SPA hook now falls back to the deprecated alias once when the canonical path returns 401/404 or a non-JSON body, then stays on it for the session. 403 (the cross-site gate) and 5xx apply to both paths and do not retry. This is a bridge, not the fix: document the handler-scoping constraint so affected operators narrow their pattern before the alias is removed in #970. Fallback requests send X-TSJS-Page-Bids: fallback so the deprecation signal stays honest. Traffic from a pre-rename bundle ages out on its own and logs at info; a current bundle falling back does not age out, because the cause is deployment configuration, and logs at warn with a config hint. Collapsing the two would make #970 wait forever on a config problem. The gate checks the header's presence, not its value, so the diagnostic does not weaken it. Also on the alias: attach the RFC 9745 deprecation link relation so remaining traffic is countable from edge logs without app-log access, and run the cross-site gate before the not-configured 404 so alias traffic on deployments without creative opportunities is still counted (and so a cross-site caller cannot probe that configuration). RFC 9745's companion Deprecation field is omitted: it carries a date, and there is no source of truth here for when the alias was deprecated. --- CHANGELOG.md | 1 + crates/trusted-server-core/src/auth.rs | 36 +++ crates/trusted-server-core/src/publisher.rs | 234 +++++++++++++++--- .../lib/src/integrations/gpt/index.ts | 107 +++++++- .../test/integrations/gpt/spa_hook.test.ts | 117 +++++++++ docs/guide/configuration.md | 25 ++ 6 files changed, 479 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f19e0ee2b..f29355745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. +- 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. ### Security diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index c42f0f3ca..8e70aa020 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -258,6 +258,42 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + /// A handler regex is matched against the whole request path, so a broad + /// pattern such as `^/_ts` covers browser-facing endpoints in that + /// namespace — `/_ts/page-bids` and `/_ts/api/v1/identify` — not just the + /// admin routes it was probably meant for. Anonymous browser fetches never + /// carry Basic credentials, so those endpoints answer `401` for every + /// visitor. + /// + /// This pins the behaviour rather than exempting the paths: which paths a + /// handler covers is the operator's decision, and silently carving holes in + /// it would be worse than a documented constraint. Operators must scope + /// handler patterns to the paths they mean (`^/_ts/admin`) — see the + /// configuration guide. The tsjs client's `/__ts/page-bids` fallback keeps + /// affected deployments serving SPA ads until they do, but it disappears + /// with the alias in IABTechLab/trusted-server#970. + #[test] + fn broad_handler_regex_also_covers_browser_facing_endpoints() { + let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "^/_ts""#); + let settings = Settings::from_toml(&config).expect("should parse broad handler regex"); + + for path in [ + "https://example.com/_ts/page-bids?path=/article", + "https://example.com/_ts/api/v1/identify", + ] { + let req = build_request(Method::GET, path); + + let response = enforce_basic_auth(&settings, &req) + .expect("should evaluate auth") + .unwrap_or_else(|| panic!("should challenge {path} under a `^/_ts` handler")); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "a `^/_ts` handler should challenge {path}" + ); + } + } + #[test] fn challenge_admin_path_with_missing_credentials() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 47394b82d..d5ff6c271 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3424,11 +3424,28 @@ pub const PAGE_BIDS_PATH: &str = "/_ts/page-bids"; /// delivers ads for in-session navigations. Adapters route it to the same handler /// so those clients keep working. /// -/// Removal is tracked by IABTechLab/trusted-server#970: drop this const and its -/// four adapter registrations once access logs show no remaining traffic on the -/// legacy path. +/// The alias is bidirectional in practice: the current tsjs bundle requests +/// [`PAGE_BIDS_PATH`] first and falls back here when that path does not serve +/// page-bids on a deployment. That covers a server rolled back to before the +/// rename, and an operator `[[handlers]]` auth regex broad enough to cover +/// `/_ts` (which would answer the canonical path with `401`). Both are +/// transitional — an affected operator must narrow the regex before the alias +/// is removed. +/// +/// Removal is tracked by IABTechLab/trusted-server#970: drop this const, its +/// four adapter registrations, and the client fallback once access logs show no +/// remaining traffic on the legacy path. pub const PAGE_BIDS_LEGACY_PATH: &str = "/__ts/page-bids"; +/// `X-TSJS-Page-Bids` value the current tsjs bundle sends when it retries +/// [`PAGE_BIDS_LEGACY_PATH`] because [`PAGE_BIDS_PATH`] was unusable. +/// +/// Separates the two populations on the deprecated alias: pre-rename bundles +/// (which age out by themselves) from current bundles falling back (which do +/// not, because the cause is deployment configuration). See the logging in +/// [`handle_page_bids`]. +pub const PAGE_BIDS_FALLBACK_MARKER: &str = "fallback"; + /// Same-origin gate for `/_ts/page-bids`. /// /// The endpoint is a side-effecting GET: it dispatches real PBS/APS auctions @@ -3516,19 +3533,9 @@ pub async fn handle_page_bids( ec_context: &EcContext, req: Request, ) -> Result, Report> { - let Some(co_config) = &settings.creative_opportunities else { - let mut response = Response::new(EdgeBody::from("Creative opportunities not configured")); - *response.status_mut() = StatusCode::NOT_FOUND; - return Ok(response); - }; - - // Trusted request origin for absolute inline creative URLs — derived from the - // origin the visitor is actually on (scheme, host, port), not the configured - // publisher domain, which cannot carry a port and may differ by subdomain. - let request_info = RequestInfo::from_request(&req, services.client_info()); - let page_bids_request_origin = request_origin(&request_info.scheme, &request_info.host); - - // CSRF-style gate: refuse cross-site invocations before any auction work. + // CSRF-style gate: refuse cross-site invocations before any other work — + // including the not-configured 404 below, which would otherwise tell a + // cross-site caller whether this deployment has creative opportunities. if !page_bids_request_allowed(&req) { log::debug!( "page-bids: rejecting request (sec-fetch-site={:?}, tsjs header present={})", @@ -3540,22 +3547,60 @@ pub async fn handle_page_bids( return Ok(page_bids_preflight_denied()); } - // Deprecation signal for the transition alias. Logged only after the - // cross-site gate passes, so the count reflects genuine SPA clients still - // running a pre-rename tsjs bundle rather than anything a third-party page - // can inflate. This is the only in-app signal that - // `PAGE_BIDS_LEGACY_PATH` is still in use — the removal precondition in - // IABTechLab/trusted-server#970 is "no remaining traffic on the legacy - // path", which is otherwise only answerable from edge access logs. The line - // is self-limiting: it goes silent as old bundles age out, which is exactly - // the condition being waited on. - if req.uri().path() == PAGE_BIDS_LEGACY_PATH { - log::info!( - "page-bids: served deprecated alias {PAGE_BIDS_LEGACY_PATH} \ - (pre-rename tsjs bundle); see IABTechLab/trusted-server#970" - ); + // Deprecation signal for the transition alias. Evaluated after the + // cross-site gate, so the count reflects genuine SPA clients still running a + // pre-rename tsjs bundle rather than anything a third-party page can + // inflate — and before the not-configured 404 below, so deployments with no + // creative opportunities still report their alias traffic instead of + // reading as zero. The log line and the response marker are the only in-app + // signals that `PAGE_BIDS_LEGACY_PATH` is still in use — the removal + // precondition in IABTechLab/trusted-server#970 is "no remaining traffic on + // the legacy path", which is otherwise only answerable from edge access + // logs. + // + // Two different clients land here, and they age out differently, so the log + // separates them by the `X-TSJS-Page-Bids` value. A pre-rename bundle sends + // `1` and disappears on its own as caches turn over. The current bundle + // sends `fallback` and does *not* — it only reaches the alias when the + // canonical path is unusable on this deployment (an operator `[[handlers]]` + // regex covering `/_ts`, or a server rolled back past the rename), which + // persists until that is fixed. Treating the two as one number would make + // #970 wait forever on a config problem. The value is client-supplied, so + // it is a diagnostic hint only; the gate above does not trust it. + let is_legacy_alias = req.uri().path() == PAGE_BIDS_LEGACY_PATH; + if is_legacy_alias { + let is_client_fallback = req + .headers() + .get("x-tsjs-page-bids") + .and_then(|value| value.to_str().ok()) + == Some(PAGE_BIDS_FALLBACK_MARKER); + if is_client_fallback { + log::warn!( + "page-bids: served deprecated alias {PAGE_BIDS_LEGACY_PATH} to a current \ + tsjs bundle that could not use {PAGE_BIDS_PATH} — check `[[handlers]]` for \ + a pattern covering `/_ts`; see IABTechLab/trusted-server#970" + ); + } else { + log::info!( + "page-bids: served deprecated alias {PAGE_BIDS_LEGACY_PATH} \ + (pre-rename tsjs bundle); see IABTechLab/trusted-server#970" + ); + } } + let Some(co_config) = &settings.creative_opportunities else { + let mut response = Response::new(EdgeBody::from("Creative opportunities not configured")); + *response.status_mut() = StatusCode::NOT_FOUND; + mark_deprecated_alias(&mut response, is_legacy_alias); + return Ok(response); + }; + + // Trusted request origin for absolute inline creative URLs — derived from the + // origin the visitor is actually on (scheme, host, port), not the configured + // publisher domain, which cannot carry a port and may differ by subdomain. + let request_info = RequestInfo::from_request(&req, services.client_info()); + let page_bids_request_origin = request_origin(&request_info.scheme, &request_info.host); + let path_param = req .uri() .query() @@ -3765,10 +3810,35 @@ pub async fn handle_page_bids( header::CACHE_CONTROL, HeaderValue::from_static("private, no-store"), ); + mark_deprecated_alias(&mut response, is_legacy_alias); Ok(response) } +/// Marks a response served through [`PAGE_BIDS_LEGACY_PATH`] as deprecated. +/// +/// Attaches the RFC 9745 `deprecation` link relation pointing at the removal +/// issue, so CDN and edge log pipelines can measure remaining legacy traffic +/// from any vantage point — the removal precondition in +/// IABTechLab/trusted-server#970 is otherwise answerable only from application +/// logs. RFC 9745's companion `Deprecation` field is deliberately omitted: it +/// carries a date, and there is no source of truth here for when the alias was +/// deprecated. +/// +/// No-op for the canonical path. +fn mark_deprecated_alias(response: &mut Response, is_legacy_alias: bool) { + if !is_legacy_alias { + return; + } + + response.headers_mut().insert( + header::LINK, + HeaderValue::from_static( + "; rel=\"deprecation\"", + ), + ); +} + #[cfg(test)] mod tests { use std::future::Future as _; @@ -8132,6 +8202,14 @@ mod tests { Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") } + /// Settings for a deployment that has no `[creative_opportunities]` + /// section, so page-bids answers `404`. + fn settings_without_co() -> Settings { + let toml = format!("{}\n[auction]\nenabled = true\n", crate_test_settings_str()); + Settings::from_toml(&toml) + .expect("should parse settings without creative_opportunities") + } + fn settings_with_co_auction_disabled() -> Settings { let toml = format!( "{}\n[auction]\nenabled = false\n\n[creative_opportunities]\ngam_network_id = \"12345\"\n", @@ -8298,6 +8376,102 @@ mod tests { ); } + /// Traffic on the deprecated alias must be measurable from edge access + /// logs, not just application logs: the removal precondition in + /// IABTechLab/trusted-server#970 is "no remaining traffic on the legacy + /// path", and operators who cannot read app logs need a response-side + /// marker to count. + #[tokio::test] + async fn deprecated_alias_response_is_marked_deprecated() { + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + + let canonical = run_page_bids_response( + &settings, + &orchestrator, + &article_slot(), + make_page_bids_request_on(PAGE_BIDS_PATH, "/2024/01/my-article/"), + ) + .await; + let alias = run_page_bids_response( + &settings, + &orchestrator, + &article_slot(), + make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), + ) + .await; + + assert_eq!( + alias + .headers() + .get(header::LINK) + .and_then(|value| value.to_str().ok()), + Some( + "; rel=\"deprecation\"" + ), + "alias response should carry the RFC 9745 deprecation link relation" + ); + assert!( + !canonical.headers().contains_key(header::LINK), + "canonical path should not be marked deprecated" + ); + } + + /// A deployment without creative opportunities answers page-bids with a + /// 404, but its alias traffic still has to be counted — otherwise a + /// silent legacy signal on such a config reads as "no remaining + /// traffic" when evaluating IABTechLab/trusted-server#970. + #[tokio::test] + async fn deprecated_alias_is_marked_without_creative_opportunities() { + let settings = settings_without_co(); + assert!( + settings.creative_opportunities.is_none(), + "test settings should have no creative opportunities configured" + ); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + + let response = run_page_bids_response( + &settings, + &orchestrator, + &[], + make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "should 404 when creative opportunities are not configured" + ); + assert!( + response.headers().contains_key(header::LINK), + "alias 404 should still be marked deprecated so it is countable" + ); + } + + /// The cross-site gate runs before the not-configured 404, so a + /// cross-site caller cannot probe whether a deployment has creative + /// opportunities configured. + #[tokio::test] + async fn cross_site_request_is_denied_before_configuration_is_revealed() { + let settings = settings_without_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut req = Request::builder() + .method(Method::GET) + .uri(format!("https://test-publisher.com{PAGE_BIDS_PATH}?path=/")) + .body(EdgeBody::empty()) + .expect("should build test request"); + set_test_header(&mut req, "sec-fetch-site", "cross-site"); + + let response = run_page_bids_response(&settings, &orchestrator, &[], req).await; + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "cross-site request should be denied, not answered with the 404" + ); + } + #[tokio::test] async fn cross_site_fetch_metadata_is_rejected() { let settings = settings_with_co(); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index d0de0b9bc..f62eb2057 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -651,6 +651,77 @@ interface PageBidsResponse { bids: Record; } +/** Canonical SPA re-auction endpoint. Mirrors `PAGE_BIDS_PATH` in Rust. */ +const PAGE_BIDS_PATH = '/_ts/page-bids'; + +/** + * Deprecated alias of {@link PAGE_BIDS_PATH}, kept registered server-side so + * pre-rename bundles keep working. This bundle falls back to it when the + * canonical path does not serve page-bids: a server rolled back to before the + * rename does not register the canonical path, and an operator `[[handlers]]` + * auth regex broad enough to cover `/_ts` answers it with `401` that no + * anonymous browser fetch can satisfy. Without the fallback either case + * silently drops ads on every SPA navigation. + * + * Removed together with the server-side alias in IABTechLab/trusted-server#970. + */ +const PAGE_BIDS_LEGACY_PATH = '/__ts/page-bids'; + +/** + * `X-TSJS-Page-Bids` value sent on a fallback request, so the server can tell + * a current bundle that could not use the canonical path from a pre-rename + * bundle that only knows the alias. Only the former signals a deployment that + * needs fixing. Mirrors `PAGE_BIDS_FALLBACK_MARKER` in Rust. + */ +const PAGE_BIDS_FALLBACK_MARKER = 'fallback'; + +/** Outcome of one page-bids request against a specific endpoint path. */ +interface PageBidsAttempt { + /** Parsed payload, or `null` when this endpoint did not serve one. */ + data: PageBidsResponse | null; + /** + * The response says this path does not serve page-bids on this deployment, + * so the other registered path is worth trying. Transient failures and the + * endpoint's own cross-site denial apply equally to both paths and do not + * set this. + */ + wrongEndpoint: boolean; +} + +async function fetchPageBids( + endpoint: string, + path: string, + signal: AbortSignal +): Promise { + const res = await fetch(`${endpoint}?path=${encodeURIComponent(path)}`, { + credentials: 'include', + // Non-simple header doubles as a CSRF token: the server rejects + // requests that carry neither same-origin Fetch Metadata nor this + // header, and cross-origin pages cannot send it without a CORS + // preflight the endpoint never grants. The server checks presence, not + // value, so the value carries the fallback diagnostic. + headers: { + 'X-TSJS-Page-Bids': endpoint === PAGE_BIDS_LEGACY_PATH ? PAGE_BIDS_FALLBACK_MARKER : '1', + }, + signal, + }); + if (!res.ok) { + // 401: an operator auth handler regex covers this path. 404: this server + // does not know the route. Either way the other path may still answer. + // 403 (cross-site gate) and 5xx would repeat on both, so they do not. + return { data: null, wrongEndpoint: res.status === 401 || res.status === 404 }; + } + try { + return { data: (await res.json()) as PageBidsResponse, wrongEndpoint: false }; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') throw err; + // A server with no route for this path proxies it to the publisher origin, + // which answers 200 HTML. An unparseable body is the wrong endpoint, not a + // transient failure. + return { data: null, wrongEndpoint: true }; + } +} + /** * Upper bound (ms) on how long the SPA hook waits for a route's ad containers * to appear before applying bids anyway. @@ -726,6 +797,29 @@ export function installSpaAuctionHook(): void { // mid-flight and B then fails, rolling back to A (never loaded) would strand // it behind the no-op guard, so we roll back to the last applied route instead. let lastAppliedPath = location.pathname; + // Endpoint this session requests. Starts canonical; if the deployment does + // not serve page-bids there, one navigation retries on the deprecated alias + // and the session stays on it rather than re-probing every navigation. + let pageBidsEndpoint = PAGE_BIDS_PATH; + + async function requestPageBids( + path: string, + signal: AbortSignal + ): Promise { + const attempt = await fetchPageBids(pageBidsEndpoint, path, signal); + if (attempt.data || !attempt.wrongEndpoint || pageBidsEndpoint !== PAGE_BIDS_PATH) { + return attempt.data; + } + + const fallback = await fetchPageBids(PAGE_BIDS_LEGACY_PATH, path, signal); + if (!fallback.data) return null; + log.warn( + `SPA auction hook: ${PAGE_BIDS_PATH} does not serve page-bids here, ` + + `falling back to ${PAGE_BIDS_LEGACY_PATH}` + ); + pageBidsEndpoint = PAGE_BIDS_LEGACY_PATH; + return fallback.data; + } async function onNavigate(path: string): Promise { if (path === currentPath) return; @@ -735,16 +829,8 @@ export function installSpaAuctionHook(): void { inflight = controller; try { - const res = await fetch(`/_ts/page-bids?path=${encodeURIComponent(path)}`, { - credentials: 'include', - // Non-simple header doubles as a CSRF token: the server rejects - // requests that carry neither same-origin Fetch Metadata nor this - // header, and cross-origin pages cannot send it without a CORS - // preflight the endpoint never grants. - headers: { 'X-TSJS-Page-Bids': '1' }, - signal: controller.signal, - }); - if (!res.ok) { + const data = await requestPageBids(path, controller.signal); + if (!data) { // A transient page-bids failure must not strand this route: roll the // committed path back so a later navigation here retries instead of // being skipped by the no-op guard at the top. Only roll back when no @@ -752,7 +838,6 @@ export function installSpaAuctionHook(): void { if (inflight === controller) currentPath = lastAppliedPath; return; } - const data = (await res.json()) as PageBidsResponse; if (inflight !== controller) return; // Defer applying bids until the new route's ad containers exist, so a // fast edge response cannot beat the DOM and drop server-side bids. diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 7dc29989c..29a8a3896 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -406,6 +406,123 @@ describe('installSpaAuctionHook', () => { expect(ts.adSlots).toEqual([{ id: 'a', div_id: 'div-a' }]); }); + it('falls back to the deprecated alias when the canonical path is behind Basic Auth', async () => { + // An operator `[[handlers]]` regex broad enough to cover `/_ts` answers the + // canonical path with 401 that no anonymous browser fetch can satisfy. + // Without the fallback, every SPA navigation on that deployment loses ads. + document.body.innerHTML = '
'; + fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + slots: [{ id: 's1', div_id: 'div-s1' }], + bids: { s1: { hb_pb: '1.00' } }, + }), + }); + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + history.pushState({}, '', '/auth-gated'); + await flushAsync(); + + expect(fetchStub).toHaveBeenNthCalledWith( + 1, + '/_ts/page-bids?path=%2Fauth-gated', + expect.anything() + ); + // The fallback marks itself so the server can separate a current bundle + // that could not use the canonical path (a deployment to fix) from a + // pre-rename bundle (which ages out on its own). + expect(fetchStub).toHaveBeenNthCalledWith( + 2, + '/__ts/page-bids?path=%2Fauth-gated', + expect.objectContaining({ headers: { 'X-TSJS-Page-Bids': 'fallback' } }) + ); + expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); + expect(adInit).toHaveBeenCalledTimes(1); + }); + + it('falls back to the deprecated alias when the canonical path returns a non-JSON body', async () => { + // A server rolled back to before the rename does not register the canonical + // path, so it falls through to the publisher-origin proxy and answers 200 + // HTML. That is the wrong endpoint, not a transient failure. + document.body.innerHTML = '
'; + fetchStub + .mockResolvedValueOnce({ + ok: true, + json: async () => { + throw new SyntaxError('Unexpected token <'); + }, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + slots: [{ id: 's1', div_id: 'div-s1' }], + bids: { s1: { hb_pb: '1.00' } }, + }), + }); + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + + history.pushState({}, '', '/rolled-back'); + await flushAsync(); + + expect(fetchStub).toHaveBeenNthCalledWith( + 2, + '/__ts/page-bids?path=%2Frolled-back', + expect.anything() + ); + expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); + }); + + it('stays on the alias for the rest of the session once the fallback works', async () => { + // Re-probing the canonical path on every navigation would double the + // request count for the whole session on an affected deployment. + document.body.innerHTML = '
'; + fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValue({ + ok: true, + json: async () => ({ + slots: [{ id: 's1', div_id: 'div-s1' }], + bids: { s1: { hb_pb: '1.00' } }, + }), + }); + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + + history.pushState({}, '', '/first'); + await flushAsync(); + history.pushState({}, '', '/second'); + await flushAsync(); + + expect(fetchStub).toHaveBeenCalledTimes(3); + expect(fetchStub).toHaveBeenNthCalledWith( + 3, + '/__ts/page-bids?path=%2Fsecond', + expect.anything() + ); + }); + + it('does not retry the alias when the endpoint denies the request', async () => { + // 403 is the cross-site gate, which applies to both registered paths — the + // alias would deny it identically, so retrying only burns a request. + fetchStub.mockResolvedValue({ ok: false, status: 403 }); + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + history.pushState({}, '', '/denied'); + await flushAsync(); + + expect(fetchStub).toHaveBeenCalledTimes(1); + expect(ts.adSlots).toBeUndefined(); + expect(adInit).not.toHaveBeenCalled(); + }); + it('is idempotent — repeated install calls do not double-fetch a navigation', async () => { fetchStub.mockResolvedValue({ ok: true, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ad91a6026..8d3e5b251 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -635,6 +635,31 @@ path = "^/api/v[0-9]+/private" # /api/v1/private, /api/v2/private **Validation**: Application startup fails if regex is invalid. +::: warning Scope patterns to the paths you mean + +Handler patterns are matched against the full request path, so a broad pattern +covers everything beneath it. The `/_ts/` namespace holds both admin routes and +browser-facing endpoints that anonymous visitors must be able to reach: + +| Path | Called by | +| ------------------------ | ------------------------------------ | +| `/_ts/page-bids` | Trusted Server JS, on SPA navigation | +| `/_ts/api/v1/identify` | Trusted Server JS, in the browser | +| `/_ts/api/v1/batch-sync` | Trusted Server JS, in the browser | + +A pattern such as `path = "^/_ts"` puts those behind Basic Auth. Browser +fetches never carry Basic credentials, so every visitor gets `401` — on +`/_ts/page-bids` that means no ads after any client-side navigation. Match the +admin routes specifically (`^/_ts/admin`) instead. + +Upgrading from a release before `/_ts/page-bids` existed: if any handler +pattern covers it, narrow the pattern. The Trusted Server JS bundle falls back +to the deprecated `/__ts/page-bids` alias in the meantime, but that alias is +scheduled for removal +([#970](https://github.com/IABTechLab/trusted-server/issues/970)). + +::: + ### Security Considerations **Password Storage**: