diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 1552ee4fd..728cc1efe 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -109,16 +109,29 @@ impl DispatchedAuction { const PROVIDER_ERROR_MESSAGE_CHARS: usize = 500; -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"; +pub(crate) const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response"; +pub(crate) const ERROR_TYPE_LAUNCH_FAILED: &str = "launch_failed"; +pub(crate) const ERROR_TYPE_TRANSPORT: &str = "transport"; +pub(crate) 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"; +/// Every server-owned `error_type` classification. +/// +/// Consumers that reproduce these values — notably the `ts-debug` redaction +/// layer in [`crate::publisher`] — validate against this list so a new +/// classification cannot silently disappear from their output. +pub(crate) const ERROR_TYPE_ALL: &[&str] = &[ + ERROR_TYPE_PARSE_RESPONSE, + ERROR_TYPE_LAUNCH_FAILED, + ERROR_TYPE_TRANSPORT, + ERROR_TYPE_TIMEOUT, + ERROR_TYPE_HTTP_STATUS, +]; + // SECURITY: the returned string is included verbatim (truncated to // PROVIDER_ERROR_MESSAGE_CHARS) in the public /auction response via // ProviderSummary.metadata["message"]. Providers MUST NOT interpolate diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index d95f82567..7d445089a 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -39,7 +39,9 @@ use crate::auction::endpoints::{ }; use crate::auction::formats::sanitize_publisher_page_url; use crate::auction::orchestrator::{ - AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, + AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, ERROR_TYPE_ALL, + ERROR_TYPE_HTTP_STATUS, ERROR_TYPE_LAUNCH_FAILED, ERROR_TYPE_PARSE_RESPONSE, + ERROR_TYPE_TIMEOUT, ERROR_TYPE_TRANSPORT, }; use crate::auction::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, @@ -61,7 +63,10 @@ use crate::platform::{GeoInfo, PlatformBackendSpec, PlatformHttpRequest, Runtime use crate::price_bucket::{PriceGranularity, price_bucket}; use crate::response_privacy::enforce_synthesized_html_cache_privacy; use crate::rsc_flight::RscFlightUrlRewriter; -use crate::settings::Settings; +use crate::settings::{ + AUCTION_DEBUG_METADATA_ALLOWLIST, AUCTION_DEBUG_UPSTREAM_METADATA_KEYS, + AuctionDebugCommentFormat, AuctionDebugCommentOptions, AuctionDebugCommentVerbosity, Settings, +}; use crate::streaming_processor::{ BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, PipelineConfig, STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline, @@ -1871,24 +1876,6 @@ pub(crate) fn write_bids_to_state( /// enabled cannot bloat every page render without bound. const MAX_AUCTION_DEBUG_DUMP_BYTES: usize = 256 * 1024; -/// Provider-metadata keys safe to surface in the on-page `ts-debug` dump. -/// -/// Fail-closed allowlist: any key not listed — notably `debug`, which carries -/// the resolved `OpenRTB` request (EC ID, `user.ext.eids`, the TC consent string, -/// `device.ip`, and `device.geo`) plus per-bidder `httpcalls` — is dropped so a -/// visitor's identity graph cannot reach the client-readable DOM even when -/// `[integration.prebid].debug` is also enabled. Full debug detail remains -/// available server-side via `log::trace!`. -const DEBUG_DUMP_METADATA_ALLOWLIST: &[&str] = &[ - "error_type", - "status", - "message", - "responsetimemillis", - "errors", - "warnings", - "bidstatus", -]; - /// Per-bid creative preview length (in bytes) in the `ts-debug` dump. Mirrors /// the 512-byte upstream-body preview the prebid provider logs on an HTTP error /// (`integrations/prebid.rs`): enough to identify a creative without copying @@ -1906,19 +1893,117 @@ fn truncate_with_marker(value: &str, max: usize) -> String { format!("{}…(truncated {} bytes)", &value[..end], value.len() - end) } -/// Build a redacted JSON view of a single provider response for the `ts-debug` -/// dump: only [`DEBUG_DUMP_METADATA_ALLOWLIST`] metadata keys survive, and each -/// bid's creative is previewed to [`MAX_BID_CREATIVE_DUMP_BYTES`]. +/// Return a recognized server-owned provider error classification. +/// +/// Validates against [`ERROR_TYPE_ALL`] rather than a local literal list so a +/// classification added in the orchestrator cannot drift out of the dump. +fn validated_error_type( + metadata: &std::collections::HashMap, +) -> Option<&str> { + let value = metadata.get("error_type")?.as_str()?; + ERROR_TYPE_ALL.contains(&value).then_some(value) +} + +/// Return a valid HTTP response status from provider metadata. +fn validated_http_status( + metadata: &std::collections::HashMap, +) -> Option { + metadata + .get("http_status")? + .as_u64() + .filter(|status| (100..=599).contains(status)) +} + +/// Generate public diagnostic wording without copying provider-controlled text. +/// +/// Every [`ERROR_TYPE_ALL`] entry must map to wording here; the +/// `redacted_metadata_covers_every_orchestrator_error_type` test fails when a +/// new orchestrator classification is added without one. +fn safe_error_message(error_type: &str, http_status: Option) -> Option { + match error_type { + ERROR_TYPE_PARSE_RESPONSE => Some("Provider response could not be parsed".to_string()), + ERROR_TYPE_LAUNCH_FAILED => Some("Provider launch failed".to_string()), + ERROR_TYPE_TRANSPORT => Some("Provider request failed".to_string()), + ERROR_TYPE_TIMEOUT => Some("Provider request timed out".to_string()), + ERROR_TYPE_HTTP_STATUS => Some(http_status.map_or_else( + || "Provider returned an HTTP error".to_string(), + |status| format!("Provider returned HTTP {status}"), + )), + _ => None, + } +} + +/// Reconstruct the configured response metadata from validated values. +fn redacted_metadata_for_dump( + metadata: &std::collections::HashMap, + options: &AuctionDebugCommentOptions, +) -> serde_json::Map { + let selected = |key: &str| { + AUCTION_DEBUG_METADATA_ALLOWLIST.contains(&key) + && options + .metadata_keys + .iter() + .any(|candidate| candidate == key) + }; + let error_type = validated_error_type(metadata); + let http_status = validated_http_status(metadata); + let mut safe = serde_json::Map::new(); + + if selected("error_type") + && let Some(value) = error_type + { + safe.insert("error_type".to_string(), serde_json::json!(value)); + } + if selected("http_status") + && let Some(value) = http_status + { + safe.insert("http_status".to_string(), serde_json::json!(value)); + } + if selected("message") + && let Some(value) = error_type.and_then(|kind| safe_error_message(kind, http_status)) + { + safe.insert("message".to_string(), serde_json::json!(value)); + } + + safe +} + +/// Build a JSON view of a single provider response for the `ts-debug` dump. +/// +/// `Redacted` reconstructs only schema-validated response metadata, `Upstream` +/// adds six named provider diagnostics, and `Full` copies every metadata value. fn redact_response_for_dump( response: &crate::auction::types::AuctionResponse, + options: &AuctionDebugCommentOptions, ) -> serde_json::Value { - let metadata: serde_json::Map = response - .metadata - .iter() - .filter(|(key, _)| DEBUG_DUMP_METADATA_ALLOWLIST.contains(&key.as_str())) - .map(|(key, value)| (key.clone(), value.clone())) - .collect(); - let bids: Vec = response.bids.iter().map(redact_bid_for_dump).collect(); + let metadata: serde_json::Map = match options.verbosity { + AuctionDebugCommentVerbosity::Redacted => { + redacted_metadata_for_dump(&response.metadata, options) + } + AuctionDebugCommentVerbosity::Upstream => { + let mut metadata = redacted_metadata_for_dump(&response.metadata, options); + for key in AUCTION_DEBUG_UPSTREAM_METADATA_KEYS { + if let Some(value) = response.metadata.get(*key) { + metadata.insert((*key).to_string(), value.clone()); + } + } + metadata + } + AuctionDebugCommentVerbosity::Full => response + .metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + }; + let bids: Vec = if options.include_bids { + response + .bids + .iter() + .map(|bid| redact_bid_for_dump(bid, options)) + .collect() + } else { + Vec::new() + }; serde_json::json!({ "provider": response.provider, "status": response.status, @@ -1928,23 +2013,31 @@ fn redact_response_for_dump( }) } -/// Build a redacted JSON view of a single bid: every field except `creative`, -/// which is previewed to [`MAX_BID_CREATIVE_DUMP_BYTES`]. -fn redact_bid_for_dump(bid: &crate::auction::types::Bid) -> serde_json::Value { +/// Build a JSON view of a single bid. `Redacted` and `Upstream` preview the +/// creative to [`MAX_BID_CREATIVE_DUMP_BYTES`]; `Full` passes it through. +fn redact_bid_for_dump( + bid: &crate::auction::types::Bid, + options: &AuctionDebugCommentOptions, +) -> serde_json::Value { let mut value = serde_json::to_value(bid).unwrap_or(serde_json::Value::Null); - if let Some(creative) = &bid.creative { + if options.verbosity != AuctionDebugCommentVerbosity::Full + && let Some(creative) = &bid.creative + { value["creative"] = serde_json::Value::String(truncate_with_marker(creative, MAX_BID_CREATIVE_DUMP_BYTES)); } value } -/// Prepend a `` HTML comment carrying a redacted view of -/// the auction result — pipeline stats plus, per provider, its status, bids -/// (each creative previewed to [`MAX_BID_CREATIVE_DUMP_BYTES`]), and allowlisted -/// metadata — onto the shared `ad_bids_state` so it lands directly before the -/// injected bids `