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
12 changes: 11 additions & 1 deletion crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use trusted_server_core::settings::Settings;
use trusted_server_core::settings_data::{
default_config_key, default_config_store_name, get_settings_from_config_store,
};
use trusted_server_core::trace_cookie::handle_trace_mode;

use trusted_server_core::platform::RuntimeServices;

Expand Down Expand Up @@ -255,6 +256,7 @@ enum NamedRouteHandler {
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
/// reach the publisher fallback (which would leak admin credentials).
LegacyAdminDenied,
TraceMode,
Auction,
PageBids,
FirstPartyProxy,
Expand All @@ -279,7 +281,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 @@ -320,6 +322,11 @@ fn named_routes() -> [NamedRoute; 12] {
primary_methods: LEGACY_ADMIN_DENY_METHODS,
handler: NamedRouteHandler::LegacyAdminDenied,
},
NamedRoute {
path: "/_ts/trace",
primary_methods: &[Method::GET],
handler: NamedRouteHandler::TraceMode,
},
NamedRoute {
path: "/auction",
primary_methods: &[Method::POST],
Expand Down Expand Up @@ -389,6 +396,9 @@ fn named_route_handler(
Ok(resp)
}
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
NamedRouteHandler::TraceMode => {
handle_trace_mode(&state.settings, req.uri().query())
}
NamedRouteHandler::Auction => {
// Build the geo-aware EC context so the auction consent
// gate sees the caller's jurisdiction — `EcContext::default()`
Expand Down
10 changes: 10 additions & 0 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use trusted_server_core::request_signing::{
handle_trusted_server_discovery, handle_verify_signature,
};
use trusted_server_core::settings::Settings;
use trusted_server_core::trace_cookie::handle_trace_mode;

use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware};
use crate::platform::build_runtime_services;
Expand Down Expand Up @@ -461,6 +462,15 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
.post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async {
Ok::<Response, EdgeError>(admin_key_management_not_supported())
})
// Render-trace toggle: arms/disarms the ts-trace cookie and
// redirects to `/`. Gated by [debug] trace_route_enabled (404 when
// off).
.get(
"/_ts/trace",
make_handler(Arc::clone(&state), |s, _services, req| async move {
handle_trace_mode(&s.settings, req.uri().query())
}),
)
.post(
"/auction",
make_handler(Arc::clone(&state), |s, services, req| async move {
Expand Down
58 changes: 58 additions & 0 deletions crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
//! | GET | `/_ts/api/v1/identify` | [`handle_identify`] |
//! | GET | `/_ts/set-tester` | [`handle_set_tester`] |
//! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] |
//! | GET | `/_ts/trace` | [`handle_trace_mode`] |
//! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] |
//! | POST | `/auction` | [`handle_auction`] |
//! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] |
Expand Down Expand Up @@ -128,6 +129,7 @@ use trusted_server_core::settings_data::{
default_config_key, default_config_store_name, get_settings_from_config_store,
};
use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester};
use trusted_server_core::trace_cookie::handle_trace_mode;

use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware};
use crate::platform::{
Expand Down Expand Up @@ -587,6 +589,7 @@ async fn run_named_route(
}
NamedRouteHandler::SetTester => handle_set_tester(&state.settings),
NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings),
NamedRouteHandler::TraceMode => handle_trace_mode(&state.settings, req.uri().query()),
NamedRouteHandler::Auction => {
// The auction reads consent data, so the consent KV store must be
// available — fail closed with 503 when it is configured but
Expand Down Expand Up @@ -994,6 +997,7 @@ enum NamedRouteHandler {
Identify,
SetTester,
ClearTester,
TraceMode,
Auction,
PageBids,
FirstPartyProxy,
Expand Down Expand Up @@ -1075,6 +1079,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[
primary_methods: &[Method::GET],
handler: NamedRouteHandler::ClearTester,
},
NamedRoute {
path: "/_ts/trace",
primary_methods: &[Method::GET],
handler: NamedRouteHandler::TraceMode,
},
NamedRoute {
path: "/auction",
primary_methods: &[Method::POST],
Expand Down Expand Up @@ -1746,6 +1755,55 @@ mod tests {
);
}

#[test]
fn dispatch_trace_route_is_disabled_by_default() {
let router = test_router();
let response = route(&router, empty_request(Method::GET, "/_ts/trace"));

assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"disabled trace route should return 404"
);
assert!(
response.headers().get(header::SET_COOKIE).is_none(),
"disabled trace route should not set a cookie"
);
}

#[test]
fn dispatch_trace_route_arms_cookie_and_redirects() {
let mut settings = test_settings();
settings.debug.trace_route_enabled = true;
let state = app_state_for_settings(settings);
let router = TrustedServerApp::routes_for_state(&state);
let response = route(&router, empty_request(Method::GET, "/_ts/trace"));

assert_eq!(
response.status(),
StatusCode::FOUND,
"enabled trace route should redirect to root"
);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/"),
"trace route should redirect to /"
);
let set_cookie = response
.headers()
.get(header::SET_COOKIE)
.expect("should set trace cookie")
.to_str()
.expect("should render set-cookie as utf-8");
assert!(
set_cookie.starts_with("ts-trace=1;"),
"trace route should arm the ts-trace cookie"
);
}

#[test]
fn dispatch_set_tester_sets_cookie_on_configured_domain() {
let mut settings = test_settings();
Expand Down
20 changes: 19 additions & 1 deletion crates/trusted-server-adapter-spin/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use trusted_server_core::request_signing::{
handle_trusted_server_discovery, handle_verify_signature,
};
use trusted_server_core::settings::Settings;
use trusted_server_core::trace_cookie::handle_trace_mode;

use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware};
use crate::platform::build_runtime_services;
Expand Down Expand Up @@ -141,14 +142,15 @@ 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]),
("/_ts/admin/keys/rotate", &[Method::POST]),
("/_ts/admin/keys/deactivate", &[Method::POST]),
("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS),
("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS),
("/_ts/trace", &[Method::GET]),
("/auction", &[Method::POST]),
("/__ts/page-bids", &[Method::GET, Method::OPTIONS]),
("/first-party/proxy", &[Method::GET]),
Expand Down Expand Up @@ -541,6 +543,21 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
}
};

// GET /_ts/trace — render-trace toggle: arms/disarms the ts-trace
// cookie and redirects to `/`. Gated by [debug] trace_route_enabled
// (404 when off).
let s = Arc::clone(&state);
let trace_mode_handler = move |ctx: RequestContext| {
let s = Arc::clone(&s);
async move {
let req = ctx.into_request();
Ok::<Response, EdgeError>(
handle_trace_mode(&s.settings, req.uri().query())
.unwrap_or_else(|e| http_error(&e)),
)
}
};

// GET /__ts/page-bids — SPA re-auction endpoint.
let s = Arc::clone(&state);
let page_bids_handler = move |ctx: RequestContext| {
Expand Down Expand Up @@ -730,6 +747,7 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
// credentials and key-management payloads to the origin.
.post("/_ts/admin/keys/rotate", admin_not_supported_handler)
.post("/_ts/admin/keys/deactivate", admin_not_supported_handler)
.get("/_ts/trace", trace_mode_handler)
.post("/auction", auction_handler)
.get("/__ts/page-bids", page_bids_handler)
.route(
Expand Down
124 changes: 122 additions & 2 deletions crates/trusted-server-core/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::collections::HashMap;
use uuid::Uuid;

use crate::auction::context::ContextValue;
use crate::auction::types::adm_trace_hash;
use crate::consent::ConsentContext;
use crate::constants::{HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED};
use crate::creative;
Expand Down Expand Up @@ -275,15 +276,57 @@ pub fn convert_to_openrtb_response(
String::new()
};

// Trace hash over the exact markup delivered to the client (post
// sanitize/rewrite) so the client can stamp the rendered creative with
// a value that matches this response byte-for-byte. Logged at info so
// server logs join against the DOM markers without debug logging.
let adm_hash = (!creative_html.is_empty()).then(|| adm_trace_hash(&creative_html));
if let Some(ref hash) = adm_hash {
log::info!(
"auction delivered creative: auction_id={} slot_id={} bidder={} crid={:?} adm_hash={}",
auction_request.id,
slot_id,
bid.bidder,
bid.crid,
hash,
);
}
let mut ts_ext = serde_json::Map::new();
ts_ext.insert(
"auction_id".to_string(),
serde_json::Value::String(auction_request.id.clone()),
);
if let Some(ref hash) = adm_hash {
ts_ext.insert(
"adm_hash".to_string(),
serde_json::Value::String(hash.clone()),
);
}
let mut bid_ext = serde_json::Map::new();
bid_ext.insert("ts".to_string(), serde_json::Value::Object(ts_ext));

let openrtb_bid = OpenRtbBid {
id: Some(format!("{}-{}", bid.bidder, slot_id)),
// Prefer the bidder's real bid ID; fall back to the legacy
// synthetic value so consumers of the old shape keep working.
id: Some(
bid.bid_id
.clone()
.unwrap_or_else(|| format!("{}-{}", bid.bidder, slot_id)),
),
impid: Some(slot_id.to_string()),
price: Some(price),
adm: Some(creative_html),
crid: Some(format!("{}-creative", bid.bidder)),
// Prefer the bidder's real creative ID; fall back to the legacy
// synthetic value so consumers of the old shape keep working.
crid: Some(
bid.crid
.clone()
.unwrap_or_else(|| format!("{}-creative", bid.bidder)),
),
w: width,
h: height,
adomain: bid.adomain.clone().unwrap_or_default(),
ext: Some(bid_ext),
..Default::default()
};

Expand Down Expand Up @@ -438,6 +481,8 @@ mod tests {
nurl: None,
burl: None,
ad_id: None,
bid_id: None,
crid: None,
cache_id: None,
cache_host: None,
cache_path: None,
Expand Down Expand Up @@ -932,6 +977,81 @@ mod tests {
);
}

#[test]
fn convert_to_openrtb_response_includes_trace_ext_with_delivered_adm_hash() {
let settings = make_settings();
let auction_request = make_auction_request();
let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75)));

let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
.expect("should convert auction result to OpenRTB response");

let json = response_json(response);
let bid = &json["seatbid"][0]["bid"][0];
assert_eq!(
bid["ext"]["ts"]["auction_id"],
json!("auction-1"),
"should carry the auction ID in the trace ext"
);
let delivered_adm = bid["adm"].as_str().expect("should serialize adm as string");
assert_eq!(
bid["ext"]["ts"]["adm_hash"],
json!(adm_trace_hash(delivered_adm)),
"should hash the exact adm delivered to the client"
);
}

#[test]
fn convert_to_openrtb_response_omits_adm_hash_without_creative() {
let settings = make_settings();
let auction_request = make_auction_request();
let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
bid.creative = None;
let result = make_result(bid);

let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
.expect("should convert bid without creative HTML");

let json = response_json(response);
let ts_ext = json["seatbid"][0]["bid"][0]["ext"]["ts"]
.as_object()
.expect("should serialize trace ext as object");
assert_eq!(
ts_ext.get("auction_id"),
Some(&json!("auction-1")),
"should still carry the auction ID"
);
assert!(
!ts_ext.contains_key("adm_hash"),
"should omit adm_hash when there is no creative"
);
}

#[test]
fn convert_to_openrtb_response_prefers_upstream_crid_and_bid_id() {
let settings = make_settings();
let auction_request = make_auction_request();
let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
bid.crid = Some("cr-12345".to_string());
bid.bid_id = Some("bid-abc-1".to_string());
let result = make_result(bid);

let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
.expect("should convert bid with upstream crid and bid ID");

let json = response_json(response);
assert_eq!(
json["seatbid"][0]["bid"][0]["crid"],
json!("cr-12345"),
"should pass through the bidder's creative ID instead of the synthetic fallback"
);
assert_eq!(
json["seatbid"][0]["bid"][0]["id"],
json!("bid-abc-1"),
"should pass through the bidder's bid ID instead of the synthetic fallback"
);
}

#[test]
fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() {
let settings = make_settings();
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/auction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub use telemetry::{
};
pub use types::{
AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType,
adm_trace_hash,
};

/// Type alias for provider builder functions.
Expand Down
Loading