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
51 changes: 49 additions & 2 deletions crates/trusted-server-core/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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))
}
Expand Down
92 changes: 91 additions & 1 deletion crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!("<provider_responses serialize error: {e}>"));
let mediator_dump = serde_json::to_string_pretty(&result.mediator_response)
.map(neutralise_comment_terminators)
.unwrap_or_else(|e| format!("<mediator_response serialize error: {e}>"));
let debug_comment = format!(
"<!-- ts-debug: path={path_label} ssp={ssp_count} mediator={mediator_info} winning={} time={}ms -->",
"<!-- ts-debug: path={path_label} ssp={ssp_count} mediator={mediator_info} winning={} time={}ms\n\
provider_responses={providers_dump}\n\
mediator_response={mediator_dump}\n\
-->",
result.winning_bids.len(),
result.total_time_ms,
);
Expand Down Expand Up @@ -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("<div>evil-->break</div>");
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,
};
Expand Down
Loading