diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index 15fe8dba..61892df6 100644 --- a/crates/js/lib/build-all.mjs +++ b/crates/js/lib/build-all.mjs @@ -12,7 +12,7 @@ * TSJS_PREBID_ADAPTERS — Comma-separated list of Prebid.js bid adapter * names to include in the bundle (e.g. "rubicon,appnexus,openx"). * Each name must have a corresponding {name}BidAdapter.js module in - * the prebid.js package. Default: "rubicon". + * the prebid.js package. Default: no adapters. * * TSJS_PREBID_USER_ID_MODULES — Ignored for production builds. User ID * modules are selected from src/integrations/prebid/user_id_modules.json @@ -37,7 +37,8 @@ const integrationsDir = path.join(srcDir, 'integrations'); // Prebid adapter generation // --------------------------------------------------------------------------- -const DEFAULT_PREBID_ADAPTERS = 'rubicon'; +const DEFAULT_PREBID_ADAPTERS = ''; +const DEFAULT_PREBID_ADAPTERS_DESCRIPTION = DEFAULT_PREBID_ADAPTERS || 'no adapters'; const ADAPTERS_FILE = path.join(integrationsDir, 'prebid', '_adapters.generated.ts'); const USER_IDS_FILE = path.join(integrationsDir, 'prebid', '_user_ids.generated.ts'); @@ -69,20 +70,12 @@ const LIVE_INTENT_SHIM = path.join( * logged and skipped. */ function generatePrebidAdapters() { - const raw = process.env.TSJS_PREBID_ADAPTERS || DEFAULT_PREBID_ADAPTERS; + const raw = process.env.TSJS_PREBID_ADAPTERS ?? DEFAULT_PREBID_ADAPTERS; const names = raw .split(',') .map((s) => s.trim()) .filter(Boolean); - if (names.length === 0) { - console.warn( - '[build-all] TSJS_PREBID_ADAPTERS is empty, falling back to default:', - DEFAULT_PREBID_ADAPTERS - ); - names.push(DEFAULT_PREBID_ADAPTERS); - } - const modulesDir = path.join(__dirname, 'node_modules', 'prebid.js', 'modules'); // Validate each adapter and build import lines @@ -100,22 +93,26 @@ function generatePrebidAdapters() { } if (imports.length === 0) { - console.error( - '[build-all] WARNING: No valid Prebid adapters found, bundle will have no client-side adapters' - ); + if (names.length === 0) { + console.log( + '[build-all] No Prebid adapters configured; bundle will have no client-side adapters' + ); + } else { + console.error( + '[build-all] WARNING: No valid Prebid adapters found, bundle will have no client-side adapters' + ); + } } - const content = [ + const header = [ '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', '//', '// Controls which Prebid.js bid adapters are included in the bundle.', '// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list', '// of adapter names (e.g. "rubicon,appnexus,openx") before building.', - `// Default: "${DEFAULT_PREBID_ADAPTERS}"`, - '', - ...imports, - '', + `// Default: ${DEFAULT_PREBID_ADAPTERS_DESCRIPTION}`, ].join('\n'); + const content = imports.length === 0 ? `${header}\n` : `${header}\n\n${imports.join('\n')}\n`; fs.writeFileSync(ADAPTERS_FILE, content); diff --git a/crates/js/lib/src/integrations/prebid/_adapters.generated.ts b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts index baf65ce9..e73f6aea 100644 --- a/crates/js/lib/src/integrations/prebid/_adapters.generated.ts +++ b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts @@ -3,6 +3,4 @@ // Controls which Prebid.js bid adapters are included in the bundle. // Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list // of adapter names (e.g. "rubicon,appnexus,openx") before building. -// Default: "rubicon" - -import 'prebid.js/modules/rubiconBidAdapter.js'; +// Default: no adapters diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 15ad3221..fd59c778 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -12,6 +12,44 @@ use super::config::AuctionConfig; use super::provider::AuctionProvider; use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +const PROVIDER_ERROR_MESSAGE_CHARS: usize = 500; + +pub(crate) const ERROR_TYPE_HTTP_STATUS: &str = "http_status"; +const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response"; +const ERROR_TYPE_LAUNCH_FAILED: &str = "launch_failed"; + +// 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 +// upstream-controlled content (response bodies, parse errors, headers) into +// their TrustedServerError::*.message fields. Use static text and log details +// server-side with `log::warn!` instead. +fn provider_error_message(error: &Report) -> String { + error + .current_context() + .to_string() + .chars() + .take(PROVIDER_ERROR_MESSAGE_CHARS) + .collect() +} + +fn provider_error_response( + provider_name: &str, + response_time_ms: u64, + error_type: &str, + error: &Report, +) -> AuctionResponse { + AuctionResponse::error(provider_name, response_time_ms) + .with_metadata("error_type", serde_json::json!(error_type)) + .with_metadata("message", serde_json::json!(provider_error_message(error))) +} + +fn provider_launch_failed_response(provider_name: &str, response_time_ms: u64) -> AuctionResponse { + AuctionResponse::error(provider_name, response_time_ms) + .with_metadata("error_type", serde_json::json!(ERROR_TYPE_LAUNCH_FAILED)) + .with_metadata("message", serde_json::json!("Provider launch failed")) +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, @@ -252,6 +290,7 @@ impl AuctionOrchestrator { let mut backend_to_provider: HashMap = HashMap::new(); let mut pending_requests: Vec = Vec::new(); + let mut responses = Vec::new(); for provider_name in provider_names { let provider = match self.providers.get(provider_name) { @@ -331,11 +370,16 @@ impl AuctionOrchestrator { ); } Err(e) => { + let response_time_ms = start_time.elapsed().as_millis() as u64; log::warn!( "Provider '{}' failed to launch request: {:?}", provider.provider_name(), e ); + responses.push(provider_launch_failed_response( + provider.provider_name(), + response_time_ms, + )); } } } @@ -357,7 +401,6 @@ impl AuctionOrchestrator { // transport timeout fires). Hard deadline enforcement therefore depends // on every backend's `first_byte_timeout` being set to at most the // remaining auction budget — which Phase 1 above guarantees. - let mut responses = Vec::new(); let mut remaining = pending_requests; while !remaining.is_empty() { @@ -397,8 +440,12 @@ impl AuctionOrchestrator { provider_name, e ); - responses - .push(AuctionResponse::error(provider_name, response_time_ms)); + responses.push(provider_error_response( + provider_name, + response_time_ms, + ERROR_TYPE_PARSE_RESPONSE, + &e, + )); } } } else { @@ -602,9 +649,11 @@ mod tests { use crate::auction::config::AuctionConfig; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionRequest, Bid, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionRequest, Bid, BidStatus, MediaType, PublisherInfo, UserInfo, }; + use crate::error::TrustedServerError; use crate::test_support::tests::crate_test_settings_str; + use error_stack::Report; use fastly::Request; use std::collections::{HashMap, HashSet}; @@ -657,6 +706,81 @@ mod tests { crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") } + #[test] + fn provider_error_response_includes_diagnostic_metadata() { + let error = Report::new(TrustedServerError::Auction { + message: "parse failed".to_string(), + }) + .attach("internal/source.rs:12:34"); + + let response = + super::provider_error_response("prebid", 37, super::ERROR_TYPE_PARSE_RESPONSE, &error); + + assert_eq!( + response.status, + BidStatus::Error, + "should mark diagnostic provider responses as errors" + ); + assert_eq!( + response.metadata["error_type"], + serde_json::json!("parse_response"), + "should include the provider error classification" + ); + + let message = response.metadata["message"] + .as_str() + .expect("should include provider error message"); + assert!( + message.contains("parse failed"), + "should include user-safe diagnostic detail" + ); + assert!( + !message.contains("internal/source.rs"), + "should not include attached internal details" + ); + } + + #[test] + fn launch_failed_response_has_safe_static_message() { + let response = super::provider_launch_failed_response("prebid", 58); + + assert_eq!( + response.status, + BidStatus::Error, + "should mark launch failures as errors" + ); + assert_eq!( + response.metadata["error_type"], + serde_json::json!("launch_failed"), + "should include launch_failed classification" + ); + assert_eq!( + response.metadata["message"], + serde_json::json!("Provider launch failed"), + "should use a safe, stable public launch failure message" + ); + } + + #[test] + fn provider_error_message_truncates_user_safe_context() { + let long_message = "x".repeat(super::PROVIDER_ERROR_MESSAGE_CHARS + 100); + let error = Report::new(TrustedServerError::Auction { + message: long_message, + }); + + let message = super::provider_error_message(&error); + + assert_eq!( + message.chars().count(), + super::PROVIDER_ERROR_MESSAGE_CHARS, + "should cap provider error messages" + ); + assert!( + message.starts_with("Auction error: "), + "should preserve the current context display text" + ); + } + #[test] fn filters_winning_bids_below_floor() { let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 418b5b1a..63b60e3d 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value as Json; use validator::Validate; +use crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, @@ -33,6 +34,7 @@ use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; use crate::settings::{IntegrationConfig, Settings}; const PREBID_INTEGRATION_ID: &str = "prebid"; +const PREBID_REASON_EMPTY_RESPONSE: &str = "empty_response"; const TRUSTED_SERVER_BIDDER: &str = "trustedServer"; const BIDDER_PARAMS_KEY: &str = "bidderParams"; const ZONE_KEY: &str = "zone"; @@ -40,6 +42,23 @@ const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. const DEFAULT_CURRENCY: &str = "USD"; +/// Maximum number of characters from upstream failure payloads included in +/// debug-facing `body_preview` metadata. +const PREBID_ERROR_BODY_PREVIEW_CHARS: usize = 1000; + +/// Maximum number of bytes processed when constructing debug-facing upstream +/// failure previews. +const PREBID_ERROR_BODY_PREVIEW_BYTES: usize = PREBID_ERROR_BODY_PREVIEW_CHARS * 4; + +fn prebid_body_preview(body: &[u8]) -> String { + let bounded_body = &body[..body.len().min(PREBID_ERROR_BODY_PREVIEW_BYTES)]; + + String::from_utf8_lossy(bounded_body) + .chars() + .take(PREBID_ERROR_BODY_PREVIEW_CHARS) + .collect() +} + /// CCPA/US-privacy string sent when the `Sec-GPC` header signals opt-out. /// /// Encodes: version `1`, notice given (`Y`), user opted out (`Y`), LSPA not @@ -1252,9 +1271,9 @@ impl PrebidAuctionProvider { } if bids.is_empty() { - AuctionResponse::no_bid("prebid", response_time_ms) + AuctionResponse::no_bid(PREBID_INTEGRATION_ID, response_time_ms) } else { - AuctionResponse::success("prebid", bids, response_time_ms) + AuctionResponse::success(PREBID_INTEGRATION_ID, bids, response_time_ms) } } @@ -1377,27 +1396,54 @@ impl PrebidAuctionProvider { response_time_ms: u64, ) -> Result> { // Parse response + let status = response.get_status(); let body_bytes = response.take_body_bytes(); - if !response.get_status().is_success() { + if !status.is_success() { log::warn!( - "Prebid returned non-success status: {}", - response.get_status(), + "Prebid returned non-success status: {status}; {} bytes", + body_bytes.len() ); - 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)] - ); + + let mut auction_response = + AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) + .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) + .with_metadata("http_status", serde_json::json!(status.as_u16())); + if self.config.debug { + let body_preview = prebid_body_preview(&body_bytes); + if !body_preview.is_empty() { + log::debug!("Prebid non-success response body: {body_preview}"); + auction_response = auction_response + .with_metadata("body_preview", serde_json::json!(body_preview)); + } } - return Ok(AuctionResponse::error("prebid", response_time_ms)); + + return Ok(auction_response); } - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { - message: "Failed to parse Prebid response".to_string(), - })?; + if body_bytes.is_empty() { + log::info!( + "Prebid returned successful empty response with status {status}; treating as no-bid" + ); + return Ok( + AuctionResponse::no_bid(PREBID_INTEGRATION_ID, response_time_ms) + .with_metadata("reason", serde_json::json!(PREBID_REASON_EMPTY_RESPONSE)) + .with_metadata("http_status", serde_json::json!(status.as_u16())), + ); + } + + let response_json: Json = match serde_json::from_slice(&body_bytes) { + Ok(response_json) => response_json, + Err(error) => { + log::warn!( + "Prebid: failed to parse response JSON (status {status}, {} bytes): {error}", + body_bytes.len() + ); + return Err(Report::new(TrustedServerError::Prebid { + message: "Failed to parse Prebid response JSON".to_string(), + })); + } + }; // Log the full response body when debug is enabled to surface // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. @@ -1425,7 +1471,7 @@ impl PrebidAuctionProvider { impl AuctionProvider for PrebidAuctionProvider { fn provider_name(&self) -> &'static str { - "prebid" + PREBID_INTEGRATION_ID } fn request_bids( @@ -3189,28 +3235,63 @@ server_url = "https://prebid.example" assert_eq!(routes.len(), 0); } - /// Verifies body-preview truncation keeps a UTF-8 char boundary. #[test] - fn body_preview_truncation_is_utf8_safe() { - // 999 ASCII bytes + U+2603 SNOWMAN (3 bytes: E2 98 83) = 1002 bytes. - // Byte index 1000 lands on 0x98, the second byte of the snowman. - let mut body = "x".repeat(999); - body.push('\u{2603}'); // ☃ - assert_eq!(body.len(), 1002); - - let truncation_index = body.floor_char_boundary(1000); - assert!( - body.is_char_boundary(truncation_index), - "should truncate at a valid UTF-8 boundary" + fn prebid_body_preview_truncates_to_character_limit() { + let body = "x".repeat(PREBID_ERROR_BODY_PREVIEW_CHARS + 100); + + let preview = prebid_body_preview(body.as_bytes()); + + assert_eq!( + preview.chars().count(), + PREBID_ERROR_BODY_PREVIEW_CHARS, + "should cap the upstream body preview" ); + } + + #[test] + fn prebid_body_preview_handles_non_utf8_lossily() { + let preview = prebid_body_preview(&[b'o', b'k', 0xff, b'!']); + assert_eq!( - body[..truncation_index].len(), - 999, - "should drop the partial multibyte character" + preview, "ok\u{fffd}!", + "should replace invalid UTF-8 bytes without panicking" ); + } + + #[test] + fn prebid_body_preview_ignores_bytes_after_bounded_slice() { + let mut body = vec![b'x'; PREBID_ERROR_BODY_PREVIEW_BYTES]; + body.extend_from_slice(&[0xff, b't', b'a', b'i', b'l']); + + let preview = prebid_body_preview(&body); + + assert_eq!( + preview.chars().count(), + PREBID_ERROR_BODY_PREVIEW_CHARS, + "should keep the public preview capped" + ); + assert!( + !preview.contains('\u{fffd}') && !preview.contains("tail"), + "should not process bytes beyond the bounded preview slice" + ); + } + + #[test] + fn prebid_body_preview_bounds_partial_utf8_at_byte_boundary() { + let mut body = vec![b'a'; PREBID_ERROR_BODY_PREVIEW_BYTES - 1]; + body.extend_from_slice("\u{2603}".as_bytes()); + body.extend_from_slice(b"tail"); + + let preview = prebid_body_preview(&body); + assert_eq!( - truncation_index, 999, - "should step back to the previous char boundary" + preview.chars().count(), + PREBID_ERROR_BODY_PREVIEW_CHARS, + "should keep the public preview capped" + ); + assert!( + !preview.contains("tail"), + "should not include bytes beyond the bounded preview slice" ); } @@ -3287,6 +3368,154 @@ server_url = "https://prebid.example" serde_json::from_value(ext["prebid"].clone()).expect("should deserialise ext.prebid") } + #[test] + fn parse_response_non_success_returns_error_with_http_metadata() { + let provider = PrebidAuctionProvider::new(base_config()); + let response = Response::from_status(StatusCode::BAD_REQUEST).with_body("invalid request"); + + let auction_response = provider + .parse_response(response, 58) + .expect("should convert non-success status to provider error"); + + assert_eq!( + auction_response.status, + crate::auction::types::BidStatus::Error, + "should mark non-success upstream responses as errors" + ); + assert_eq!( + auction_response.metadata["error_type"], + json!("http_status"), + "should classify the error source" + ); + assert_eq!( + auction_response.metadata["http_status"], + json!(400), + "should include upstream HTTP status" + ); + assert!( + !auction_response.metadata.contains_key("body_preview"), + "should omit upstream body preview unless Prebid debug is enabled" + ); + } + + #[test] + fn parse_response_non_success_includes_body_preview_when_debug_enabled() { + let mut config = base_config(); + config.debug = true; + let provider = PrebidAuctionProvider::new(config); + let body = "x".repeat(PREBID_ERROR_BODY_PREVIEW_CHARS + 100); + let response = Response::from_status(StatusCode::BAD_REQUEST).with_body(body); + + let auction_response = provider + .parse_response(response, 58) + .expect("should convert non-success status to provider error"); + + let body_preview = auction_response.metadata["body_preview"] + .as_str() + .expect("should include upstream body preview in debug mode"); + assert_eq!( + body_preview.chars().count(), + PREBID_ERROR_BODY_PREVIEW_CHARS, + "should cap debug upstream body preview" + ); + } + + #[test] + fn parse_response_invalid_json_returns_safe_client_error() { + let provider = PrebidAuctionProvider::new(base_config()); + let response = Response::from_status(StatusCode::OK).with_body(r#"{"seatbid":["bid""#); + + let error = provider + .parse_response(response, 42) + .expect_err("should return parse failure for invalid JSON"); + + let message = format!("{error}"); + assert!( + message.contains("Failed to parse Prebid response JSON"), + "should include stable user-safe parse failure message" + ); + assert!( + !message.contains("expected value"), + "should not leak serde parse details" + ); + assert!( + !message.contains("bytes"), + "should not leak response length in the user-safe message" + ); + } + + #[test] + fn parse_response_no_content_returns_no_bid_with_reason() { + let provider = PrebidAuctionProvider::new(base_config()); + let response = Response::from_status(StatusCode::NO_CONTENT); + + let auction_response = provider + .parse_response(response, 42) + .expect("should convert no-content status to no-bid"); + + assert_eq!( + auction_response.status, + crate::auction::types::BidStatus::NoBid, + "should treat 204 as a no-bid response" + ); + assert_eq!( + auction_response.metadata["reason"], + json!("empty_response"), + "should explain why the provider returned no bids" + ); + assert_eq!( + auction_response.metadata["http_status"], + json!(204), + "should include upstream HTTP status" + ); + } + + #[test] + fn parse_response_ok_empty_body_returns_no_bid_with_reason() { + let provider = PrebidAuctionProvider::new(base_config()); + let response = Response::from_status(StatusCode::OK); + + let auction_response = provider + .parse_response(response, 17) + .expect("should convert empty successful response to no-bid"); + + assert_eq!( + auction_response.status, + crate::auction::types::BidStatus::NoBid, + "should treat empty 200 as a no-bid response" + ); + assert_eq!( + auction_response.metadata["reason"], + json!("empty_response"), + "should explain why the provider returned no bids" + ); + assert_eq!( + auction_response.metadata["http_status"], + json!(200), + "should include upstream HTTP status" + ); + } + + #[test] + fn parse_response_valid_json_without_bids_returns_no_bid() { + let provider = PrebidAuctionProvider::new(base_config()); + let response = Response::from_status(StatusCode::OK).with_body(r#"{"seatbid":[]}"#); + + let auction_response = provider + .parse_response(response, 23) + .expect("should parse valid no-bid JSON"); + + assert_eq!( + auction_response.status, + crate::auction::types::BidStatus::NoBid, + "should preserve valid JSON no-bid behavior" + ); + assert!( + auction_response.metadata.is_empty(), + "should not add empty-response metadata for valid no-bid JSON" + ); + } + // ======================================================================== // bid_param_overrides tests // ======================================================================== diff --git a/trusted-server.toml b/trusted-server.toml index 5ab1a750..7f04e277 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -69,7 +69,7 @@ debug = false # Bidders that run client-side via native Prebid.js adapters instead of # being routed through the server-side auction. Their adapter modules must # be statically imported in the JS bundle. -client_side_bidders = ["rubicon"] +client_side_bidders = [] # Compatibility sugar for static per-bidder params merged into every outgoing # PBS request. These normalize into bid_param_override_rules internally.