diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 85abef61..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, @@ -1627,6 +1632,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 +2013,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 +2021,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 +2038,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 +2052,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(), @@ -2429,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, @@ -2446,6 +2459,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 +4727,7 @@ mod tests { None, &ConsentContext::default(), &request_info, + "publisher.example.com", Some("Mozilla/5.0"), ); @@ -4724,6 +4739,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 +4803,7 @@ mod tests { Some("ec-abc"), &ConsentContext::default(), &request_info, + "publisher.example.com", Some("Mozilla/5.0"), ); @@ -5261,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); + } + } }