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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchadm_hash is computed over two different byte-streams, so the documented log join silently fails on the /auction path.

This line hashes creative_html, i.e. the creative after sanitize_creative_html + rewrite_creative_html. But log_winning_bids (orchestrator.rs:174) and build_bid_map (publisher.rs:2146) both hash the raw bid.creative via Bid::creative_trace_hash().

One winning bid therefore emits two different adm_hash values across the two new info logs, and the value the client stamps into the DOM depends on which path served it. The doc comments on stampCreativeTrace, build_bid_map, and the trace.ts header all explicitly promise that the DOM marker "joins back to the server-side auction winner: log lines" — on the /auction path that join can never match.

I checked the SSAT / inject_adm_for_testing path and it is self-consistent (the bid map's adm is also raw bid.creative), so this is scoped to /auction.

Fix — either hash one canonical form on both paths, or name the two distinctly and log both on the winner line so the relationship is explicit (e.g. adm_hash_raw on auction winner:, adm_hash_delivered here).


Additionally: this PR should be stacked on top of #916. That PR ("Make auction creative rewriting optional") rewrites this exact block — it replaces the rewritten binding with a processed one gated on settings.auction.rewrite_creatives. Two consequences:

  1. Textual conflictMake auction creative rewriting optional #916 and Trace winning auction bids to rendered creatives on the page #922 both edit these ~30 lines; whichever lands second will conflict here.
  2. It makes this finding config-dependent — once rewriting is optional, adm_hash becomes a hash of sanitized-only output or sanitized+rewritten output depending on rewrite_creatives. The same creative would then produce different adm_hash values across deployments, on top of already differing from the raw hash on the auction winner: line.

#916 is smaller and already MERGEABLE, so the cheap ordering is to land it first and rebase this PR onto it, defining adm_hash against #916's final processed value with the raw/delivered naming split above.

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
Loading