Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
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
Loading
Loading