From b52b415755fbffd63935a94ad3d14f8165b82440 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 11 Jul 2026 00:32:35 +0530 Subject: [PATCH 1/2] Dump full SSAT prebid responses in ts-debug auction comment The server-side auction stream path only emitted a summary counter (ssp/mediator/winning/time), so an operator seeing winning=0 could not tell whether prebid returned nothing, errored, or bid below the floor. Serialize the full provider_responses (and mediator_response) into the ts-debug HTML comment so the SSAT surfaces the same prebid server response detail available from the /auction endpoint. Bid creative and metadata are attacker/partner-influenced, so neutralize the '-->' and '--!>' comment terminators before embedding to keep the dump inside the comment and out of the live DOM. --- crates/trusted-server-core/src/publisher.rs | 92 ++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5..c1441012 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -871,8 +871,28 @@ pub(crate) fn prepend_auction_debug_comment( Some(r) => format!("ok({}_bids)", r.bids.len()), None => "none".to_string(), }; + // Full per-provider (and mediator) dump so the operator can see exactly what + // each SSP returned — `status` (nobid vs error vs success), every `bids` + // entry, and `metadata` (which carries PBS `ext.errors` / `ext.debug.httpcalls` + // when prebid `debug=true`) — without needing log access. + // + // `Bid.creative` and provider metadata are attacker/partner-influenced and + // may contain `-->` (or the `--!>` variant), which would terminate the HTML + // comment early and leak the remaining markup into the live DOM. Neutralise + // both terminators before embedding so the dump stays inside the comment. + let neutralise_comment_terminators = + |json: String| -> String { json.replace("-->", "-- >").replace("--!>", "-- !>") }; + let providers_dump = serde_json::to_string_pretty(&result.provider_responses) + .map(neutralise_comment_terminators) + .unwrap_or_else(|e| format!("")); + let mediator_dump = serde_json::to_string_pretty(&result.mediator_response) + .map(neutralise_comment_terminators) + .unwrap_or_else(|e| format!("")); let debug_comment = format!( - "", + "", result.winning_bids.len(), result.total_time_ms, ); @@ -2439,6 +2459,76 @@ mod tests { use super::*; use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::integrations::IntegrationRegistry; + + #[test] + fn auction_debug_comment_dumps_provider_status_and_neutralises_terminators() { + use crate::auction::orchestrator::OrchestrationResult; + use crate::auction::types::AuctionResponse; + + // One provider that returned nothing (the `winning=0` case) and one that + // returned a bid whose creative embeds an HTML-comment terminator. + let no_bid = AuctionResponse::no_bid("prebid", 665); + let mut bid = make_test_bid_with_creative("
evil-->break
"); + bid.slot_id = "ad-header-0".to_string(); + let with_bid = AuctionResponse::success("aps", vec![bid], 42); + + let result = OrchestrationResult { + provider_responses: vec![no_bid, with_bid], + mediator_response: None, + winning_bids: std::collections::HashMap::new(), + total_time_ms: 665, + metadata: std::collections::HashMap::new(), + }; + + let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); + prepend_auction_debug_comment("stream", &result, &state); + let comment = state + .lock() + .expect("should lock state") + .clone() + .expect("should have comment"); + + assert!( + comment.contains("\"status\": \"nobid\""), + "should surface the no-bid provider status: {comment}" + ); + assert!( + comment.contains("provider_responses="), + "should dump the provider_responses payload" + ); + // The creative's `-->` must be neutralised so the only comment terminator + // is the trailing one — otherwise embedded markup would leak into the DOM. + assert_eq!( + comment.matches("-->").count(), + 1, + "creative `-->` must be neutralised, leaving only the closing terminator: {comment}" + ); + assert!( + comment.contains("evil-- >break"), + "should retain the creative content with the terminator neutralised" + ); + } + + fn make_test_bid_with_creative(creative: &str) -> Bid { + Bid { + slot_id: "slot".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: Some(creative.to_string()), + adomain: None, + bidder: "seat".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + } + } + use crate::platform::test_support::{ build_services_with_http_client, noop_services, StubHttpClient, }; From 48db8bf6a56dd31fd2a2b7334354b12f882b8fd2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 11 Jul 2026 00:51:41 +0530 Subject: [PATCH 2/2] Surface prebid HTTP error status and body in auction dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Prebid Server returns a non-2xx status, the parser returned a bare AuctionResponse::error with empty metadata — indistinguishable in the ts-debug dump from a transport, parse, or timeout failure, all of which tag error_type. An operator seeing status=error with metadata={} had no way to know the upstream HTTP code without log access. Attach error_type=http_status, the status code, and a 512-byte body snippet to the error response metadata so the auction dump shows exactly why prebid errored (e.g. a 4xx from a PBS rejecting the request). --- .../src/integrations/prebid.rs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a8..c6fccc2c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2124,14 +2124,23 @@ impl AuctionProvider for PrebidAuctionProvider { if !status.is_success() { log::warn!("Prebid returned non-success status: {}", status,); + let body_preview = String::from_utf8_lossy(&body_bytes); if log::log_enabled!(log::Level::Trace) { - let body_preview = String::from_utf8_lossy(&body_bytes); log::trace!( "Prebid error response body: {}", &body_preview[..body_preview.floor_char_boundary(1000)] ); } - return Ok(AuctionResponse::error("prebid", response_time_ms)); + // Surface the HTTP status and a body snippet on the response metadata + // so the ts-debug auction dump shows *why* prebid errored (e.g. a 4xx + // from a PBS that rejects the unsigned server-side request) without + // needing log access. A bare `AuctionResponse::error` yields empty + // metadata, which is indistinguishable from other failures in the dump. + let body_snippet = body_preview[..body_preview.floor_char_boundary(512)].to_string(); + return Ok(AuctionResponse::error("prebid", response_time_ms) + .with_metadata("error_type", serde_json::json!("http_status")) + .with_metadata("status", serde_json::json!(status.as_u16())) + .with_metadata("body", serde_json::json!(body_snippet))); } let response_json: Json = @@ -2341,6 +2350,44 @@ mod tests { ); } + #[test] + fn parse_response_attaches_status_and_body_metadata_on_http_error() { + use crate::auction::types::BidStatus; + + let provider = PrebidAuctionProvider::new(base_config()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(403) + .body(EdgeBody::from(br#"{"error":"missing signature"}"#.to_vec())) + .expect("should build test response"), + ); + + let result = futures::executor::block_on(provider.parse_response(response, 643)) + .expect("should return Ok(error response) for non-success status"); + + assert_eq!( + result.status, + BidStatus::Error, + "non-success HTTP status should map to an error response" + ); + assert_eq!( + result.metadata["error_type"], + json!("http_status"), + "should tag the error path so the auction dump is distinguishable" + ); + assert_eq!( + result.metadata["status"], + json!(403), + "should surface the upstream HTTP status code" + ); + assert!( + result.metadata["body"] + .as_str() + .is_some_and(|body| body.contains("missing signature")), + "should include the response body snippet" + ); + } + fn test_sri(algorithm: &str, digest: &[u8]) -> String { format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) }