From cd570045ba222c334c99cd08926816e7c8ffa2e0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:45:01 -0700 Subject: [PATCH 1/2] Use configured publisher domain for navigation-path auction requests build_auction_request derived publisher.domain, site.domain, and the page URL host from the incoming request Host header. On the SSAT proxy path that header is the trusted-server edge host (e.g. the staging domain), which then leaked into the outbound OpenRTB bid request and, through it, into injected creatives and the IAS brand-safety pixel. Source these fields from settings.publisher.domain instead, matching what convert_tsjs_to_auction_request already does on the /auction endpoint path. Closes #936 --- crates/trusted-server-core/src/publisher.rs | 61 ++++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 85abef61..0f18d0c2 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1627,6 +1627,7 @@ pub async fn handle_publisher_request( ec_id, &consent_context, &request_info, + &settings.publisher.domain, req.headers() .get("user-agent") .and_then(|v| v.to_str().ok()), @@ -2007,6 +2008,7 @@ pub(crate) fn build_auction_request( ec_id: Option<&str>, consent_context: &crate::consent::ConsentContext, request_info: &crate::http_util::RequestInfo, + publisher_domain: &str, user_agent: Option<&str>, ) -> AuctionRequest { let slots = slots_ctx @@ -2014,9 +2016,13 @@ pub(crate) fn build_auction_request( .iter() .map(crate::creative_opportunities::CreativeOpportunitySlot::to_ad_slot) .collect(); + // Advertise the configured publisher domain (not the incoming edge `Host`) + // so SSPs, injected creatives, and brand-safety pixels see the publisher's + // own origin. On the SSAT proxy path `request_info.host` is the trusted + // server edge host, which must not leak into the bid request. let page_url = format!( "{}://{}{}", - request_info.scheme, request_info.host, slots_ctx.request_path + request_info.scheme, publisher_domain, slots_ctx.request_path ); let ec_id = ec_id.filter(|id| !id.is_empty()); let request_id = ec_id.map_or_else( @@ -2027,7 +2033,7 @@ pub(crate) fn build_auction_request( id: request_id, slots, publisher: PublisherInfo { - domain: request_info.host.clone(), + domain: publisher_domain.to_owned(), page_url: Some(page_url.clone()), }, user: UserInfo { @@ -2041,7 +2047,7 @@ pub(crate) fn build_auction_request( geo: None, }), site: Some(SiteInfo { - domain: request_info.host.clone(), + domain: publisher_domain.to_owned(), page: page_url, }), context: std::collections::HashMap::new(), @@ -2446,6 +2452,7 @@ pub async fn handle_page_bids( ec_id, consent_context, &request_info, + &settings.publisher.domain, req.headers() .get("user-agent") .and_then(|v| v.to_str().ok()), @@ -4713,6 +4720,7 @@ mod tests { None, &ConsentContext::default(), &request_info, + "publisher.example.com", Some("Mozilla/5.0"), ); @@ -4724,6 +4732,52 @@ mod tests { ); } + #[test] + fn auction_request_uses_configured_publisher_domain_not_edge_host() { + // On the SSAT proxy path the browser addresses the trusted-server + // edge host, but the auction must advertise the configured + // publisher domain to SSPs — otherwise injected creatives and the + // brand-safety pixel leak the edge/staging host. + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path: "/2024/01/my-article/?edition=fictional", + }; + let request_info = RequestInfo { + host: "ts.example.com".to_string(), + scheme: "https".to_string(), + }; + + let request = build_auction_request( + &slots_ctx, + None, + &ConsentContext::default(), + &request_info, + "www.example.com", + Some("Mozilla/5.0"), + ); + + assert_eq!( + request.publisher.domain, "www.example.com", + "publisher.domain should be the configured publisher domain, not the edge host" + ); + let site = request.site.expect("should populate site metadata"); + assert_eq!( + site.domain, "www.example.com", + "site.domain should be the configured publisher domain, not the edge host" + ); + assert_eq!( + request.publisher.page_url.as_deref(), + Some("https://www.example.com/2024/01/my-article/?edition=fictional"), + "page_url host should be the configured publisher domain, not the edge host" + ); + assert_eq!( + site.page, "https://www.example.com/2024/01/my-article/?edition=fictional", + "site.page host should be the configured publisher domain, not the edge host" + ); + } + #[test] fn auction_request_with_ec_id_sets_user_id_and_ec_request_id() { let slot = make_slot(); @@ -4742,6 +4796,7 @@ mod tests { Some("ec-abc"), &ConsentContext::default(), &request_info, + "publisher.example.com", Some("Mozilla/5.0"), ); From 4a020e2f170d5d10d8b9daf70189867e7c7fd357 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:50:55 -0700 Subject: [PATCH 2/2] Attribute navigation auction telemetry to the configured publisher domain The navigation-path AuctionObservationContext still took its publisher_domain from the incoming edge Host header, so telemetry rows for initial and SPA navigations were attributed to the edge/staging host while /auction rows and outbound bid requests used the configured publisher domain. Both observation constructors now use settings.publisher.domain. Add handler-level coverage for both navigation paths: the previous regression test called build_auction_request directly and would keep passing if a call site reverted to request_info.host, since both sources are &str. The new tests drive handle_publisher_request and handle_page_bids with a divergent edge host, capture the dispatched AuctionRequest through a recording provider, and assert both the bid request fields and the emitted telemetry rows carry the configured domain. Verified they fail when either the builder argument or the observation argument is reverted. --- crates/trusted-server-core/src/publisher.rs | 310 +++++++++++++++++++- 1 file changed, 308 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 0f18d0c2..34909efe 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1609,9 +1609,14 @@ pub async fn handle_publisher_request( let mut dispatched_auction = if matched_slots.is_empty() { None } else { + // Telemetry attribution must use the same publisher identity as the + // outbound bid request. On the navigation path `request_host` is the + // trusted-server edge host, so using it here would attribute navigation + // rows to the edge/staging domain while `/auction` rows (built from + // `AuctionRequest::publisher.domain`) use the configured domain. let observation = AuctionObservationContext::from_parts( AuctionSource::InitialNavigation, - request_host, + &settings.publisher.domain, &request_path, matched_slots.len(), ec_context, @@ -2435,9 +2440,11 @@ pub async fn handle_page_bids( let winning_bids = if matched_slots.is_empty() { std::collections::HashMap::new() } else { + // Same publisher identity as the outbound bid request — see the + // matching note on the initial-navigation observation above. let observation = AuctionObservationContext::from_parts( AuctionSource::SpaNavigation, - &request_info.host, + &settings.publisher.domain, &path_param, matched_slots.len(), ec_context, @@ -5316,4 +5323,303 @@ mod tests { "should report the buffer cap in the error message" ); } + + /// Handler-level coverage that both navigation paths take the publisher + /// identity from configuration rather than the incoming edge `Host` header. + /// + /// The `build_auction_request` unit test above cannot catch a call site + /// regressing to `request_info.host`, because both sources are `&str`. These + /// tests drive the real handlers with a divergent edge host and assert on + /// the auction request the orchestrator dispatched and on the telemetry rows + /// the handler emitted. + mod navigation_publisher_domain_tests { + use super::*; + use crate::auction::provider::AuctionProvider; + use crate::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; + use crate::auction::types::AuctionRequest; + use crate::auction::{AuctionContext, AuctionOrchestrator}; + use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; + use crate::platform::test_support::{ + NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, + }; + use crate::platform::{ClientInfo, PlatformPendingRequest, PlatformResponse}; + use crate::test_support::tests::crate_test_settings_str; + use std::sync::Mutex; + + /// Trusted-server edge host the browser addresses on the SSAT proxy + /// path — deliberately different from the configured publisher domain. + const EDGE_HOST: &str = "ts.example.com"; + + /// `[publisher] domain` from [`crate_test_settings_str`]. + const CONFIGURED_DOMAIN: &str = "test-publisher.com"; + + const CAPTURING_PROVIDER: &str = "request_capturing_provider"; + + /// Records the [`AuctionRequest`] the orchestrator dispatched, then + /// fails its launch so no real transport handle is needed. + struct RequestCapturingProvider { + captured: Arc>>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for RequestCapturingProvider { + fn provider_name(&self) -> &'static str { + CAPTURING_PROVIDER + } + + async fn request_bids( + &self, + request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + *self.captured.lock().expect("should lock captured request") = + Some(request.clone()); + Err(Report::new(TrustedServerError::Auction { + message: "capture only".to_string(), + })) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("parse_response must not run when the launch fails"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name( + &self, + _services: &RuntimeServices, + _timeout_ms: u32, + ) -> Option { + Some("capture-backend".to_string()) + } + } + + #[derive(Default)] + struct RecordingTelemetrySink { + batches: Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionTelemetrySink for RecordingTelemetrySink { + async fn emit_auction_events( + &self, + _services: &RuntimeServices, + batch: AuctionEventBatch, + ) -> Result<(), Report> { + self.batches + .lock() + .expect("should lock telemetry batches") + .push(batch); + Ok(()) + } + } + + fn settings_with_capturing_provider() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\nproviders = [\"{CAPTURING_PROVIDER}\"]\n\n\ + [creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml).expect("should parse settings with a capturing provider") + } + + fn article_slot() -> Vec { + vec![CreativeOpportunitySlot { + id: "atf".to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: MediaType::Banner, + }], + floor_price: Some(0.50), + targeting: Default::default(), + providers: Default::default(), + compiled_patterns: Vec::new(), + }] + } + + /// [`EcContext`] whose consent context permits the server-side auction. + fn consent_allowing_ec_context() -> EcContext { + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + EcContext::new_for_test(None, consent) + } + + fn services_with( + http_client: Arc, + telemetry_sink: Arc, + ) -> RuntimeServices { + let telemetry_sink: Arc = telemetry_sink; + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(StubBackend)) + .http_client(http_client) + .geo(Arc::new(NoopGeo)) + .auction_telemetry_sink(telemetry_sink) + .client_info(ClientInfo::default()) + .build() + } + + fn orchestrator_capturing_request( + settings: &Settings, + captured: &Arc>>, + ) -> AuctionOrchestrator { + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + orchestrator.register_provider(Arc::new(RequestCapturingProvider { + captured: Arc::clone(captured), + })); + orchestrator + } + + /// Assert the dispatched request and every emitted telemetry row carry + /// the configured publisher domain rather than the edge host. + fn assert_configured_domain( + captured: &Arc>>, + telemetry_sink: &RecordingTelemetrySink, + ) { + let request = captured + .lock() + .expect("should lock captured request") + .clone() + .expect("should dispatch an auction request"); + assert_eq!( + request.publisher.domain, CONFIGURED_DOMAIN, + "publisher.domain should be the configured publisher domain, not the edge host" + ); + let site = request.site.expect("should populate site metadata"); + assert_eq!( + site.domain, CONFIGURED_DOMAIN, + "site.domain should be the configured publisher domain, not the edge host" + ); + // Only the host is asserted: the two `AuctionRequest` builders still + // disagree on where the scheme comes from (edge-detected here, + // canonical `https` in `convert_tsjs_to_auction_request`), which is + // tracked separately. + let page_url = request + .publisher + .page_url + .as_deref() + .expect("should populate page_url"); + let page_url_host = url::Url::parse(page_url) + .expect("should build a parseable page_url") + .host_str() + .map(str::to_owned) + .expect("should populate a page_url host"); + assert_eq!( + page_url_host, CONFIGURED_DOMAIN, + "page_url host should be the configured publisher domain, not the edge host" + ); + assert_eq!( + site.page, page_url, + "site.page should mirror page_url, so it carries the configured publisher domain too" + ); + + let batches = telemetry_sink + .batches + .lock() + .expect("should lock telemetry batches"); + let rows: Vec<_> = batches.iter().flat_map(AuctionEventBatch::rows).collect(); + assert!(!rows.is_empty(), "should emit at least one telemetry row"); + for row in rows { + assert_eq!( + row.publisher_domain, CONFIGURED_DOMAIN, + "telemetry rows should be attributed to the configured publisher domain, not the edge host" + ); + } + } + + #[tokio::test] + async fn initial_navigation_advertises_configured_publisher_domain() { + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = services_with( + Arc::clone(&stub) as Arc, + Arc::clone(&telemetry_sink), + ); + let mut ec_context = consent_allowing_ec_context(); + let req = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://{EDGE_HOST}/2024/01/my-article/")) + .header(header::HOST, EDGE_HOST) + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build test request"); + + let _ = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: None, + }, + req, + ) + .await + .expect("should proxy publisher request"); + + assert_configured_domain(&captured, &telemetry_sink); + } + + #[tokio::test] + async fn page_bids_advertises_configured_publisher_domain() { + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with( + Arc::new(crate::platform::test_support::NoopHttpClient), + Arc::clone(&telemetry_sink), + ); + let ec_context = consent_allowing_ec_context(); + let mut req = HttpRequest::builder() + .method(Method::GET) + .uri(format!( + "https://{EDGE_HOST}/_ts/page-bids?path=/2024/01/my-article/" + )) + .header(header::HOST, EDGE_HOST) + .body(EdgeBody::empty()) + .expect("should build test request"); + req.headers_mut().insert( + header::HeaderName::from_static("sec-fetch-site"), + HeaderValue::from_static("same-origin"), + ); + + let _ = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: None, + }, + &ec_context, + req, + ) + .await + .expect("should return ok response"); + + assert_configured_domain(&captured, &telemetry_sink); + } + } }