Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
},
Expand Down
10 changes: 10 additions & 0 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
56 changes: 31 additions & 25 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -480,28 +481,6 @@ fn build_router(state: &Arc<AppState>) -> 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 {
Expand Down Expand Up @@ -533,6 +512,33 @@ fn build_router(state: &Arc<AppState>) -> 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())
Expand Down
10 changes: 10 additions & 0 deletions crates/trusted-server-adapter-cloudflare/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
63 changes: 58 additions & 5 deletions crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions crates/trusted-server-adapter-spin/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]),
Expand All @@ -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]),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -541,7 +543,7 @@ fn build_router(state: &Arc<AppState>) -> 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);
Expand All @@ -562,7 +564,7 @@ fn build_router(state: &Arc<AppState>) -> 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::<Response, EdgeError>(page_bids_preflight_denied())
Expand Down Expand Up @@ -731,9 +733,15 @@ fn build_router(state: &Arc<AppState>) -> 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,
)
Expand Down
50 changes: 50 additions & 0 deletions crates/trusted-server-adapter-spin/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading