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-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 ae4ca421f..a72cbb2f0 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -116,8 +116,9 @@ 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, handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, - page_bids_preflight_denied, publisher_response_into_streaming_response, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, handle_page_bids, + handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + publisher_response_into_streaming_response, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -1081,7 +1082,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, }, @@ -1209,8 +1220,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; @@ -1622,6 +1633,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/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/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 600441f95..d5ff6c271 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3409,7 +3409,44 @@ 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. +/// +/// 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 /// and forwards request-derived signals (IP, UA, geo, consent) to partners. @@ -3439,7 +3476,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. /// @@ -3448,7 +3485,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; @@ -3473,7 +3511,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. @@ -3495,9 +3533,65 @@ pub async fn handle_page_bids( ec_context: &EcContext, req: Request, ) -> Result, Report> { + // 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={})", + req.headers() + .get("sec-fetch-site") + .and_then(|v| v.to_str().ok()), + req.headers().contains_key("x-tsjs-page-bids") + ); + return Ok(page_bids_preflight_denied()); + } + + // 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); }; @@ -3507,18 +3601,6 @@ pub async fn handle_page_bids( 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. - if !page_bids_request_allowed(&req) { - log::debug!( - "page-bids: rejecting request (sec-fetch-site={:?}, tsjs header present={})", - req.headers() - .get("sec-fetch-site") - .and_then(|v| v.to_str().ok()), - req.headers().contains_key("x-tsjs-page-bids") - ); - return Ok(page_bids_preflight_denied()); - } - let path_param = req .uri() .query() @@ -3728,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 _; @@ -8095,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", @@ -8162,11 +8277,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 @@ -8217,6 +8336,142 @@ 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" + ); + } + + /// 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-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 1f5aa14d6..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. @@ -703,7 +774,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. @@ -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 9a08defcb..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 @@ -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' }) ); }); @@ -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**: