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
5 changes: 5 additions & 0 deletions crates/trusted-server-core/src/auction/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response";
const ERROR_TYPE_LAUNCH_FAILED: &str = "launch_failed";
const ERROR_TYPE_TRANSPORT: &str = "transport";
const ERROR_TYPE_TIMEOUT: &str = "timeout";
/// A non-2xx HTTP status from an upstream SSP (e.g. a PBS 4xx/5xx). Distinct
/// from [`ERROR_TYPE_TRANSPORT`] (a connection-level failure) so telemetry can
/// bucket it separately. `pub(crate)` so producers such as the prebid provider
/// tag errors with the exact value the telemetry layer recognises.
pub(crate) const ERROR_TYPE_HTTP_STATUS: &str = "http_status";

// SECURITY: the returned string is included verbatim (truncated to
// PROVIDER_ERROR_MESSAGE_CHARS) in the public /auction response via
Expand Down
40 changes: 40 additions & 0 deletions crates/trusted-server-core/src/auction/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,7 @@ fn provider_status(response: &AuctionResponse) -> &'static str {
Some("parse_response") => "parse_error",
Some("transport") => "transport_error",
Some("timeout") => "timeout",
Some("http_status") => "http_status_error",
Comment thread
prk-Jr marked this conversation as resolved.
_ => "transport_error",
},
BidStatus::Pending => "timeout",
Expand Down Expand Up @@ -1077,6 +1078,45 @@ mod tests {
);
}

#[test]
fn provider_call_maps_http_status_errors_to_their_own_bucket() {
// A non-2xx upstream status (e.g. a PBS 4xx/5xx) is tagged
// `error_type = "http_status"` by the prebid provider; telemetry must
// bucket it as `http_status_error` — distinct from a connection-level
// `transport_error` — so provider-health error rates count it.
let request = test_request("ts-ec-derived-id");
let provider_http_error = AuctionResponse::error("prebid", 12)
.with_metadata("error_type", json!("http_status"))
.with_metadata("status", json!(403));
let result = OrchestrationResult {
provider_responses: vec![provider_http_error],
mediator_response: None,
winning_bids: HashMap::new(),
total_time_ms: 12,
metadata: HashMap::new(),
};
let observation =
AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/article/1", 1);

let batch = build_auction_events(
observation,
AuctionTerminalOutcome::Completed {
request: &request,
result: &result,
},
);

assert_eq!(
batch
.rows()
.iter()
.find(|row| row.provider.as_deref() == Some("prebid"))
.and_then(|row| row.status.as_deref()),
Some("http_status_error"),
"should bucket upstream HTTP failures as http_status_error, not transport_error"
);
}

#[test]
fn mediated_win_marks_original_bid_once() {
let request = test_request("req");
Expand Down
90 changes: 80 additions & 10 deletions crates/trusted-server-core/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2122,15 +2122,33 @@ impl AuctionProvider for PrebidAuctionProvider {
})?;

if !status.is_success() {
log::warn!("Prebid returned non-success status: {}", status,);
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));
let body_preview = String::from_utf8_lossy(&body_bytes);
// SECURITY: the PBS response body is upstream-controlled and may leak
// internal detail (hostnames, stack traces, auth hints). Per the
// invariant documented in `auction/orchestrator.rs`, it MUST NOT reach
// the public `/auction` response, which happens if it lands in
// `AuctionResponse.metadata` (cloned verbatim into
// `ext.orchestrator.provider_details[].metadata`). Log the snippet
// server-side and surface only the numeric HTTP status — enough for an
// operator to tell an error from a no-bid without publishing the body.
log::warn!(
"Prebid returned non-success status {status}: {}",
&body_preview[..body_preview.floor_char_boundary(512)]
);
return Ok(AuctionResponse::error("prebid", response_time_ms)
.with_metadata(
Comment thread
prk-Jr marked this conversation as resolved.
"error_type",
serde_json::json!(crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS),
)
// Static message only (no upstream content) — mirrors the
// orchestrator's error constructors so every consumer of
// `provider_details[].metadata` sees a consistent
// `error_type` + `message` shape.
.with_metadata(
"message",
serde_json::json!("Prebid returned non-success status"),
)
.with_metadata("status", serde_json::json!(status.as_u16())));
}

let response_json: Json =
Expand Down Expand Up @@ -2232,7 +2250,8 @@ mod tests {
use super::*;
use crate::auction::test_support::create_test_auction_context as shared_test_auction_context;
use crate::auction::types::{
AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo,
AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo,
UserInfo,
};

use crate::consent::{ConsentContext, ConsentSource};
Expand Down Expand Up @@ -2340,6 +2359,57 @@ mod tests {
);
}

#[test]
fn parse_response_attaches_status_metadata_without_leaking_body_on_http_error() {
let provider = PrebidAuctionProvider::new(base_config());
let response = PlatformResponse::new(
edgezero_core::http::response_builder()
.status(403)
.body(EdgeBody::from(
br#"{"error":"upstream-secret-detail"}"#.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 telemetry buckets it as an http status error"
);
assert_eq!(
result.metadata["status"],
json!(403),
"should surface the upstream HTTP status code"
Comment thread
prk-Jr marked this conversation as resolved.
);
// A static, upstream-free message keeps the error shape consistent with
// the orchestrator's other provider error responses.
assert_eq!(
result.metadata["message"],
json!("Prebid returned non-success status"),
"should carry a static message like every other provider error path"
);
// SECURITY: the upstream response body must never reach the public
// /auction response via AuctionResponse.metadata.
assert!(
!result.metadata.contains_key("body"),
"upstream response body must not be surfaced on the response metadata"
);
assert!(
!result.metadata.values().any(|v| v
.as_str()
.is_some_and(|s| s.contains("upstream-secret-detail"))),
"no metadata value may contain the upstream body"
);
}

fn test_sri(algorithm: &str, digest: &[u8]) -> String {
format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest))
}
Expand Down
Loading
Loading