diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index c6c237e59..e17d33f1f 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -2,9 +2,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; +use validator::Validate; /// Auction orchestration configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct AuctionConfig { /// Enable the auction orchestrator @@ -35,6 +36,7 @@ pub struct AuctionConfig { /// Timeout in milliseconds #[serde(default = "default_timeout")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, /// KV store name for creative storage (deprecated: creatives are now delivered inline) @@ -106,6 +108,27 @@ impl AuctionConfig { mod tests { use super::*; + fn config_with_timeout(timeout_ms: u32) -> AuctionConfig { + AuctionConfig { + timeout_ms, + ..AuctionConfig::default() + } + } + + #[test] + fn timeout_ms_range_is_enforced() { + for good in [1, 2000, 60000] { + config_with_timeout(good) + .validate() + .unwrap_or_else(|err| panic!("timeout {good} should be accepted: {err:?}")); + } + for bad in [0, 60001] { + config_with_timeout(bad) + .validate() + .expect_err(&format!("timeout {bad} should be rejected")); + } + } + #[test] fn rewrite_creatives_defaults_to_true() { let config: AuctionConfig = diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 40bf0e6c3..b05612501 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -225,6 +225,108 @@ mod tests { settings } + /// Source-controlled operator-facing config template. + const EXAMPLE_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../trusted-server.example.toml" + )); + + /// Returns the template with its deliberately-invalid placeholder admin + /// password swapped for a valid one, so parse-time validation succeeds and + /// the test can exercise the optional blocks it uncomments. + fn template_with_valid_admin_password() -> String { + EXAMPLE_TEMPLATE.replace( + "password = \"replace-with-admin-password-32-bytes\"", + "password = \"unit-test-admin-password-that-is-long-enough\"", + ) + } + + /// Uncomments the contiguous `#`-prefixed block that begins at the line + /// `# {header}`, leaving the rest of the template untouched. Stops at the + /// first line that is not a comment (a blank line ends the block). + fn uncomment_block(template: &str, header: &str) -> String { + let header_line = format!("# {header}"); + let mut out = Vec::new(); + let mut uncommenting = false; + + for line in template.lines() { + if line == header_line { + uncommenting = true; + } else if uncommenting && !line.trim_start().starts_with('#') { + uncommenting = false; + } + + if uncommenting { + let bare = line + .strip_prefix("# ") + .or_else(|| line.strip_prefix('#')) + .unwrap_or(line); + out.push(bare.to_owned()); + } else { + out.push(line.to_owned()); + } + } + + out.join("\n") + } + + /// Every documented block should be push-ready: uncommenting it and setting + /// the shown values must parse and pass field validation. Blocks that ship + /// a deliberately-invalid placeholder (admin password, `ec.passphrase`, GTM + /// `container_id`, APS `pub_id`, `request_signing` store ids) are excluded. + #[test] + fn documented_integration_blocks_validate_when_uncommented() { + let base = template_with_valid_admin_password(); + + for (header, id) in [ + ("[integrations.permutive]", "permutive"), + ("[integrations.lockr]", "lockr"), + ("[integrations.sourcepoint]", "sourcepoint"), + ] { + let toml = uncomment_block(&base, header); + let settings = Settings::from_toml(&toml) + .unwrap_or_else(|err| panic!("uncommented {header} should parse: {err:?}")); + + match id { + "permutive" => assert!( + settings + .integration_config::(id) + .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) + .is_some(), + "{header} should resolve to an enabled, valid config" + ), + "lockr" => assert!( + settings + .integration_config::(id) + .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) + .is_some(), + "{header} should resolve to an enabled, valid config" + ), + "sourcepoint" => assert!( + settings + .integration_config::(id) + .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) + .is_some(), + "{header} should resolve to an enabled, valid config" + ), + other => panic!("unhandled integration id {other}"), + } + } + } + + /// The `[tinybird]` block is top-level and validated at parse time, so + /// uncommenting it with the documented `api_host` must parse cleanly. + #[test] + fn documented_tinybird_block_validates_when_uncommented() { + let toml = uncomment_block(&template_with_valid_admin_password(), "[tinybird]"); + let settings = Settings::from_toml(&toml) + .expect("uncommented [tinybird] with documented api_host should parse and validate"); + assert!( + settings.tinybird.enabled && !settings.tinybird.api_host.is_empty(), + "tinybird should be enabled with a non-empty api_host" + ); + } + #[test] fn wrapper_serializes_as_settings_shape() { let settings = valid_settings(); @@ -284,6 +386,196 @@ password = "production-admin-password-32-bytes" ); } + #[test] + fn deploy_validation_rejects_example_publisher_hosts() { + let mut settings = valid_settings(); + settings.publisher.domain = "example.com".to_string(); + settings.publisher.cookie_domain = ".example.com".to_string(); + settings.publisher.origin_url = "https://origin.example.com".to_string(); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject unedited example publisher hosts"); + let text = format!("{err:?}"); + + assert!( + text.contains("publisher.domain") + && text.contains("publisher.cookie_domain") + && text.contains("publisher.origin_url"), + "should flag all three example publisher placeholders: {err:?}" + ); + } + + #[test] + fn deploy_validation_rejects_placeholder_request_signing_store_ids() { + let mut settings = valid_settings(); + settings.request_signing = Some(crate::settings::RequestSigning { + enabled: true, + config_store_id: "".to_string(), + secret_store_id: "".to_string(), + }); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject placeholder request-signing store ids when enabled"); + let text = format!("{err:?}"); + + assert!( + text.contains("request_signing.config_store_id") + && text.contains("request_signing.secret_store_id"), + "should flag both request-signing store ids: {err:?}" + ); + } + + /// The rotate/deactivate admin routes are registered unconditionally and + /// read the store IDs without consulting `enabled`, so a disabled block with + /// placeholder IDs would still reach key management at runtime. + #[test] + fn deploy_validation_rejects_placeholder_store_ids_while_request_signing_is_disabled() { + let mut settings = valid_settings(); + settings.request_signing = Some(crate::settings::RequestSigning { + enabled: false, + config_store_id: "".to_string(), + secret_store_id: "".to_string(), + }); + + let err = validate_settings_for_deploy(&settings).expect_err( + "should reject placeholder store ids even while request signing is disabled", + ); + let text = format!("{err:?}"); + + assert!( + text.contains("request_signing.config_store_id") + && text.contains("request_signing.secret_store_id"), + "should flag both request-signing store ids: {err:?}" + ); + } + + #[test] + fn deploy_validation_rejects_empty_request_signing_store_ids() { + let mut settings = valid_settings(); + settings.request_signing = Some(crate::settings::RequestSigning { + enabled: true, + config_store_id: String::new(), + secret_store_id: " ".to_string(), + }); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject empty and whitespace-only store ids"); + let text = format!("{err:?}"); + + assert!( + text.contains("request_signing.config_store_id") + && text.contains("request_signing.secret_store_id"), + "should flag both request-signing store ids: {err:?}" + ); + } + + #[test] + fn deploy_validation_rejects_placeholder_aps_pub_id() { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "aps", + &serde_json::json!({ + "enabled": true, + "pub_id": "your-aps-publisher-id", + "endpoint": "https://aps.example.com/e/dtb/bid" + }), + ) + .expect("should insert APS config"); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject placeholder APS pub_id when enabled"); + + assert!( + format!("{err:?}").contains("aps"), + "should mention the APS integration: {err:?}" + ); + } + + #[test] + fn deploy_validation_rejects_blank_or_padded_aps_pub_id() { + for (label, pub_id) in [ + ("empty", ""), + ("whitespace-only", " "), + ("surrounding-whitespace", " 5128 "), + ("trailing-whitespace", "5128 "), + ] { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "aps", + &serde_json::json!({ + "enabled": true, + "pub_id": pub_id, + "endpoint": "https://aps.example.com/e/dtb/bid" + }), + ) + .expect("should insert APS config"); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject blank or padded APS pub_id when enabled"); + + assert!( + format!("{err:?}").contains("aps"), + "should mention the APS integration for {label} pub_id: {err:?}" + ); + } + } + + #[test] + fn deploy_validation_rejects_padded_request_signing_store_ids() { + let mut settings = valid_settings(); + settings.request_signing = Some(crate::settings::RequestSigning { + enabled: false, + config_store_id: " management-config-store ".to_string(), + secret_store_id: "management-secret-store ".to_string(), + }); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject store ids with surrounding whitespace"); + let text = format!("{err:?}"); + + assert!( + text.contains("request_signing.config_store_id") + && text.contains("request_signing.secret_store_id"), + "should flag both padded store ids: {err:?}" + ); + } + + /// `enabled` defaults to `false` for APS, so a section that omits the flag + /// resolves to disabled and must not have its fields validated — otherwise + /// the documented template placeholder breaks existing configs on upgrade. + #[test] + fn deploy_validation_skips_field_validation_for_integrations_with_omitted_enabled() { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "aps", + &serde_json::json!({ + "pub_id": "your-aps-publisher-id", + "endpoint": "https://aps.example.com/e/dtb/bid" + }), + ) + .expect("should insert APS config"); + // `endpoint` parses as a plain string but would fail the `url` + // validator, so this section only survives if validation is skipped for + // integrations that resolve to disabled. + settings + .integrations + .insert_config( + "adserver_mock", + &serde_json::json!({ "endpoint": "not-a-valid-url" }), + ) + .expect("should insert adserver_mock config"); + + validate_settings_for_deploy(&settings).expect( + "should skip field validation for integrations that resolve to disabled via default", + ); + } + #[test] fn deploy_validation_rejects_external_prebid_bundle_without_proxy_allowed_domains() { let mut settings = valid_settings(); diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 8fd3f9ddb..bfd2ef541 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -47,6 +47,7 @@ pub struct AdServerMockConfig { /// Timeout in milliseconds #[serde(default = "default_timeout_ms")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, /// Optional price floor (minimum acceptable CPM) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 60c22265d..66c7c3b20 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value as Json, json}; use std::collections::HashMap; use std::time::Duration; -use validator::Validate; +use validator::{Validate, ValidationError}; use crate::auction::provider::AuctionProvider; use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, MediaType}; @@ -201,6 +201,7 @@ pub struct ApsConfig { /// APS publisher ID (accepts both string and integer from config) #[serde(deserialize_with = "deserialize_pub_id")] + #[validate(custom(function = validate_aps_pub_id))] pub pub_id: String, /// APS API endpoint @@ -210,6 +211,7 @@ pub struct ApsConfig { /// Timeout in milliseconds #[serde(default = "default_timeout_ms")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, } @@ -285,6 +287,40 @@ impl Default for ApsConfig { } } +/// Reserved example `pub_id` values from the config template that must not be +/// deployed while APS is enabled. +const PUB_ID_PLACEHOLDERS: &[&str] = &["your-aps-publisher-id"]; + +/// Validator for [`ApsConfig::pub_id`]: rejects blank IDs, IDs with surrounding +/// whitespace, and the known template placeholder. Surrounding whitespace must +/// be rejected because the raw `pub_id` is forwarded into the APS request +/// payload verbatim, so a padded value would validate yet reach APS unusable. +/// This runs only when APS is enabled, because integration configs validate +/// lazily via `get_typed`. +fn validate_aps_pub_id(pub_id: &str) -> Result<(), ValidationError> { + let trimmed = pub_id.trim(); + + if trimmed.is_empty() { + let mut err = ValidationError::new("aps_pub_id_blank"); + err.message = Some("pub_id must not be blank".into()); + return Err(err); + } + + if pub_id != trimmed { + let mut err = ValidationError::new("aps_pub_id_whitespace"); + err.message = Some("pub_id must not have leading or trailing whitespace".into()); + return Err(err); + } + + if PUB_ID_PLACEHOLDERS + .iter() + .any(|placeholder| placeholder.eq_ignore_ascii_case(trimmed)) + { + return Err(ValidationError::new("aps_pub_id_placeholder")); + } + Ok(()) +} + impl IntegrationConfig for ApsConfig { fn is_enabled(&self) -> bool { self.enabled @@ -777,6 +813,25 @@ mod tests { use super::*; use crate::auction::types::*; + #[test] + fn validate_aps_pub_id_accepts_valid_and_rejects_bad_values() { + validate_aps_pub_id("5128").expect("a clean pub_id should be accepted"); + + for bad in [ + "", + " ", + " 5128 ", + "5128 ", + " 5128", + "your-aps-publisher-id", + ] { + assert!( + validate_aps_pub_id(bad).is_err(), + "pub_id {bad:?} should be rejected" + ); + } + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "test-auction-123".to_string(), diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index ff53e05f3..9b95d2cf0 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -89,7 +89,13 @@ pub struct GoogleTagManagerConfig { #[serde(default = "default_enabled")] pub enabled: bool, /// GTM Container ID (e.g., "GTM-XXXXXX"). - #[validate(length(min = 1, max = 50), custom(function = "validate_container_id"))] + #[validate( + length(min = 1, max = 50), + regex( + path = *GTM_CONTAINER_ID_PATTERN, + message = "container_id must match format GTM-XXXXXX where X is alphanumeric" + ) + )] pub container_id: String, /// Upstream URL for GTM (defaults to ). #[serde(default = "default_upstream")] @@ -128,16 +134,6 @@ fn default_max_beacon_body_size() -> usize { 65536 // 64KB - prevents memory pressure from oversized payloads } -fn validate_container_id(container_id: &str) -> Result<(), validator::ValidationError> { - if GTM_CONTAINER_ID_PATTERN.is_match(container_id) { - Ok(()) - } else { - Err(validator::ValidationError::new( - "container_id must match format GTM-XXXXXX where X is alphanumeric", - )) - } -} - /// GTM domain markers the script rewriter looks for. Kept in one place so the /// boundary-safe prefix check in [`might_contain_gtm_prefix`] and the full-match /// check in [`GoogleTagManagerIntegration::rewrite`] cannot drift apart. @@ -674,6 +670,39 @@ mod tests { use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; + #[test] + fn container_id_validation_matches_gtm_pattern() { + use validator::Validate as _; + + let config = |id: &str| -> GoogleTagManagerConfig { + serde_json::from_value(serde_json::json!({ "container_id": id })) + .expect("should deserialize GTM config") + }; + + // Well-formed container ids pass. + for good in ["GTM-ABCD", "GTM-ABCD1234", "GTM-A1B2C3D4E5"] { + config(good) + .validate() + .unwrap_or_else(|err| panic!("valid container id {good:?} should pass: {err:?}")); + } + + // Malformed ids are rejected: wrong prefix, too short, lowercase, bad + // chars, empty. + for bad in [ + "ABCD1234", + "GTM-abc", + "gtm-ABCD", + "GTM-AB", + "GTM_ABCD", + "GTM-ABCD!", + "", + ] { + config(bad) + .validate() + .expect_err(&format!("invalid container id {bad:?} should be rejected")); + } + } + use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; use http::Method; diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 73da261f3..c84ce0112 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,5 +1,5 @@ use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::Duration; use async_trait::async_trait; @@ -13,6 +13,7 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::header::HeaderValue; use http::{Method, StatusCode, header}; +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; use url::{Url, Url as ParsedUrl}; @@ -212,6 +213,7 @@ pub struct PrebidIntegrationConfig { #[serde(default)] pub account_id: Option, #[serde(default = "default_timeout_ms")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, #[serde( default = "default_bidders", @@ -242,7 +244,10 @@ pub struct PrebidIntegrationConfig { pub external_bundle_url: Option, /// Optional hex SHA-256 of the exact external bundle bytes. #[serde(default)] - #[validate(custom(function = "validate_external_bundle_sha256"))] + #[validate(regex( + path = *EXTERNAL_BUNDLE_SHA256_PATTERN, + message = "external_bundle_sha256 must be a 64-character hex SHA-256" + ))] pub external_bundle_sha256: Option, /// Optional browser Subresource Integrity value for the first-party script. #[serde(default)] @@ -435,15 +440,10 @@ fn validate_external_bundle_url(value: &str) -> Result<(), ValidationError> { Ok(()) } -fn validate_external_bundle_sha256(value: &str) -> Result<(), ValidationError> { - if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Ok(()); - } - - let mut err = ValidationError::new("invalid_external_bundle_sha256"); - err.message = Some("external_bundle_sha256 must be a 64-character hex SHA-256".into()); - Err(err) -} +/// Exact hex SHA-256: 64 hex digits. Used by the built-in `regex` validator on +/// [`PrebidIntegrationConfig::external_bundle_sha256`]. +static EXTERNAL_BUNDLE_SHA256_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^[0-9a-fA-F]{64}$").expect("SHA-256 hex regex should compile")); #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum ExternalBundleSriAlgorithm { @@ -2553,6 +2553,39 @@ mod tests { use std::collections::HashMap; use std::io::Cursor; + #[test] + fn external_bundle_sha256_validation_matches_hex_pattern() { + use validator::Validate as _; + + let config = |sha: &str| -> PrebidIntegrationConfig { + serde_json::from_value(serde_json::json!({ + "server_url": "https://prebid.example.com/openrtb2/auction", + "external_bundle_sha256": sha, + })) + .expect("should deserialize prebid config") + }; + + // Exactly 64 hex digits (either case) passes. + config(&"a".repeat(64)) + .validate() + .expect("64-char lowercase hex sha256 should pass"); + config("ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789") + .validate() + .expect("mixed-case 64-char hex sha256 should pass"); + + // Wrong length or non-hex characters are rejected. + for bad in [ + "a".repeat(63), + "a".repeat(65), + "g".repeat(64), + String::new(), + ] { + config(&bad) + .validate() + .expect_err(&format!("invalid sha256 {bad:?} should be rejected")); + } + } + fn make_settings() -> Settings { create_test_settings() } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index e193a70aa..c2751f8d7 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -120,6 +120,51 @@ impl Publisher { .any(|p| p.eq_ignore_ascii_case(proxy_secret)) } + /// Reserved example publisher values copied verbatim from the config + /// template. They deserialize fine but must be replaced before deploying. + const PLACEHOLDER_DOMAINS: &[&str] = &["example.com"]; + const PLACEHOLDER_COOKIE_DOMAINS: &[&str] = &[".example.com"]; + /// Reserved example origin hosts. Matched against the parsed URL host so a + /// spelling that resolves to the same host (an explicit `:443`, a trailing + /// slash, a different scheme) cannot slip past the placeholder check. + const PLACEHOLDER_ORIGIN_HOSTS: &[&str] = &["origin.example.com"]; + + /// Returns `true` if `domain` is the unedited template placeholder + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_domain(domain: &str) -> bool { + Self::PLACEHOLDER_DOMAINS + .iter() + .any(|p| p.eq_ignore_ascii_case(domain.trim())) + } + + /// Returns `true` if `cookie_domain` is the unedited template placeholder + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_cookie_domain(cookie_domain: &str) -> bool { + Self::PLACEHOLDER_COOKIE_DOMAINS + .iter() + .any(|p| p.eq_ignore_ascii_case(cookie_domain.trim())) + } + + /// Returns `true` if `origin_url` resolves to an unedited template + /// placeholder host (case-insensitive). + /// + /// The comparison is on the parsed URL host, not the raw string, so + /// equivalent spellings of the reserved host - an explicit default port, a + /// trailing slash, or a different scheme - are all rejected. + #[must_use] + pub fn is_placeholder_origin_url(origin_url: &str) -> bool { + Url::parse(origin_url.trim()) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .is_some_and(|host| { + Self::PLACEHOLDER_ORIGIN_HOSTS + .iter() + .any(|p| p.eq_ignore_ascii_case(&host)) + }) + } + /// Extracts the host (including port if present) from the `origin_url`. /// /// # Examples @@ -229,6 +274,15 @@ impl IntegrationSettings { }, )?; + // Field validation runs only for integrations that resolve to enabled. + // An integration whose `enabled` flag is omitted falls back to its + // serde default, which the explicit-`false` fast path above cannot + // observe. Validating before this check would reject documented + // template placeholders in sections that are not actually turned on. + if !config.is_enabled() { + return Ok(None); + } + config.validate().map_err(|err| { Report::new(TrustedServerError::Configuration { message: format!( @@ -237,10 +291,6 @@ impl IntegrationSettings { }) })?; - if !config.is_enabled() { - return Ok(None); - } - Ok(Some(config)) } } @@ -624,6 +674,34 @@ pub struct RequestSigning { pub secret_store_id: String, } +impl RequestSigning { + /// Reserved example store-id values from the config template, plus the + /// empty string, that must not be deployed while request signing is enabled. + pub const STORE_ID_PLACEHOLDERS: &[&str] = &[ + "", + "", + ]; + + /// Returns `true` if `store_id` is empty or a known template placeholder + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_store_id(store_id: &str) -> bool { + let store_id = store_id.trim(); + store_id.is_empty() + || Self::STORE_ID_PLACEHOLDERS + .iter() + .any(|p| p.eq_ignore_ascii_case(store_id)) + } + + /// Returns `true` if `store_id` cannot be deployed as-is: a placeholder, or + /// a value with surrounding whitespace that the key-management routes would + /// forward to the management API verbatim. + #[must_use] + pub fn is_unusable_store_id(store_id: &str) -> bool { + Self::is_placeholder_store_id(store_id) || store_id != store_id.trim() + } +} + fn default_request_signing_enabled() -> bool { false } @@ -1933,6 +2011,7 @@ pub struct Settings { #[validate(nested)] pub rewrite: Rewrite, #[serde(default)] + #[validate(nested)] pub auction: AuctionConfig, #[serde(default)] pub consent: ConsentConfig, @@ -2124,6 +2203,31 @@ impl Settings { insecure_fields.push(format!("handlers[{}].password", handler.path)); } } + if Publisher::is_placeholder_domain(&self.publisher.domain) { + insecure_fields.push("publisher.domain".to_owned()); + } + if Publisher::is_placeholder_cookie_domain(&self.publisher.cookie_domain) { + insecure_fields.push("publisher.cookie_domain".to_owned()); + } + if Publisher::is_placeholder_origin_url(&self.publisher.origin_url) { + insecure_fields.push("publisher.origin_url".to_owned()); + } + // Checked whenever the block is present, not just when it is enabled: + // the key rotate/deactivate admin routes are registered unconditionally + // and read these store IDs without consulting `enabled`, so placeholder + // IDs behind a disabled block would still reach key management at + // runtime. Surrounding whitespace is rejected too: the placeholder check + // trims for comparison but the raw value is what `signing_store_ids` + // forwards to `KeyRotationManager`, so a padded id would validate yet + // reach the management API unusable. + if let Some(request_signing) = &self.request_signing { + if RequestSigning::is_unusable_store_id(&request_signing.config_store_id) { + insecure_fields.push("request_signing.config_store_id".to_owned()); + } + if RequestSigning::is_unusable_store_id(&request_signing.secret_store_id) { + insecure_fields.push("request_signing.secret_store_id".to_owned()); + } + } if insecure_fields.is_empty() { return Ok(()); @@ -3030,6 +3134,79 @@ origin_host_header_overide = "www.example.com""#, ); } + #[test] + fn is_placeholder_domain_rejects_known_placeholders_case_insensitively() { + for placeholder in Publisher::PLACEHOLDER_DOMAINS { + assert!( + Publisher::is_placeholder_domain(placeholder), + "should detect placeholder domain '{placeholder}'" + ); + } + assert!( + Publisher::is_placeholder_domain(" Example.COM "), + "should detect trimmed, mixed-case placeholder domain" + ); + } + + #[test] + fn is_placeholder_domain_accepts_non_placeholder() { + assert!( + !Publisher::is_placeholder_domain("publisher.test"), + "should accept a real publisher domain" + ); + } + + #[test] + fn is_placeholder_cookie_domain_rejects_known_placeholders_case_insensitively() { + for placeholder in Publisher::PLACEHOLDER_COOKIE_DOMAINS { + assert!( + Publisher::is_placeholder_cookie_domain(placeholder), + "should detect placeholder cookie_domain '{placeholder}'" + ); + } + assert!( + Publisher::is_placeholder_cookie_domain(" .Example.COM "), + "should detect trimmed, mixed-case placeholder cookie_domain" + ); + } + + #[test] + fn is_placeholder_cookie_domain_accepts_non_placeholder() { + assert!( + !Publisher::is_placeholder_cookie_domain(".publisher.test"), + "should accept a real cookie domain" + ); + } + + #[test] + fn is_placeholder_origin_url_rejects_equivalent_spellings_of_reserved_host() { + for reserved in [ + "https://origin.example.com", + "https://origin.example.com/", + "https://origin.example.com:443", + "http://origin.example.com", + "https://Origin.Example.com", + " https://origin.example.com ", + ] { + assert!( + Publisher::is_placeholder_origin_url(reserved), + "should reject origin_url resolving to the reserved host: '{reserved}'" + ); + } + } + + #[test] + fn is_placeholder_origin_url_accepts_non_placeholder() { + assert!( + !Publisher::is_placeholder_origin_url("https://origin.publisher.test"), + "should accept a real origin url" + ); + assert!( + !Publisher::is_placeholder_origin_url("https://cdn.example.com"), + "should accept a different host under the same example domain" + ); + } + #[test] fn is_placeholder_handler_password_rejects_known_template_value() { assert!( @@ -3056,6 +3233,26 @@ origin_host_header_overide = "www.example.com""#, ); } + #[test] + fn is_unusable_store_id_rejects_placeholders_empty_and_padded_values() { + for placeholder in RequestSigning::STORE_ID_PLACEHOLDERS { + assert!( + RequestSigning::is_unusable_store_id(placeholder), + "should reject placeholder store id '{placeholder}'" + ); + } + for bad in ["", " ", " 01GCFG ", "01GCFG "] { + assert!( + RequestSigning::is_unusable_store_id(bad), + "should reject unusable store id '{bad}'" + ); + } + assert!( + !RequestSigning::is_unusable_store_id("01GCFG"), + "should accept a clean store id" + ); + } + #[test] fn test_settings_empty_toml() { let toml_str = ""; diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 939fccff5..b913c9fbd 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -1,171 +1,408 @@ +# ============================================================================= +# Trusted Server — application configuration template +# ============================================================================= +# +# This is the source-controlled starting point for an operator-owned +# `trusted-server.toml`. Copy it (`ts config init`), fill in the required +# values, and push it (`ts config push`) as an EdgeZero app-config blob. +# +# Only three sections are REQUIRED for the server to start and pass validation: +# 1. [[handlers]] covering /_ts/admin (admin authentication) +# 2. [publisher] (domain + origin) +# 3. [ec] passphrase (Edge Cookie identity secret) +# +# Everything below those is OPTIONAL. Most optional blocks are commented out — +# uncomment and edit one to enable it — but a few integrations are kept as active +# `enabled = false` stubs (see the Integrations section for why). All example +# hosts use `example.com`; replace them with your real endpoints in your private +# config, not in this template. +# +# `TRUSTED_SERVER__` env vars can override values when the `ts` CLI builds and +# validates this config for push (e.g. TRUSTED_SERVER__PUBLISHER__DOMAIN=...; +# nested keys use `__`). The overlay only replaces SCALAR leaves that already +# exist in the parsed TOML: a commented-out or absent key is silently ignored, +# and arrays or tables must be edited in TOML directly, not via env vars. The +# deployed runtime reads the pushed config blob, so these env vars do not change +# live behavior on their own. +# ============================================================================= + + +# ----------------------------------------------------------------------------- +# REQUIRED — Admin authentication +# ----------------------------------------------------------------------------- +# HTTP Basic-auth handler(s). At least one handler whose `path` regex covers +# the /_ts/admin endpoints is mandatory; startup fails without it. Each handler +# needs a non-placeholder username/password (deploy validation rejects the +# sample password below). [[handlers]] +# Regex matched against the request path. This one guards the admin surface. path = "^/_ts/admin" username = "admin" password = "replace-with-admin-password-32-bytes" +# You can add more handlers to basic-auth-protect other path prefixes. The +# sample password below is a known placeholder that deploy validation rejects, +# so it forces a real secret before push: +# [[handlers]] +# path = "^/secure" +# username = "user" +# password = "replace-with-admin-password" + + +# ----------------------------------------------------------------------------- +# REQUIRED — Publisher / origin +# ----------------------------------------------------------------------------- [publisher] +# Public domain Trusted Server is fronting. domain = "example.com" +# Cookie scope for first-party identity cookies (leading dot = include subdomains). cookie_domain = ".example.com" +# Upstream origin to proxy publisher content from. No trailing slash. origin_url = "https://origin.example.com" -# Optional: override outbound Host header while connecting to origin_url. -# origin_host_header_override = "www.example.com" +# HMAC secret for signing first-party proxy URLs. Replace before deploying. proxy_secret = "change-me-proxy-secret" +# Optional: override the outbound Host header sent to origin_url. +# origin_host_header_override = "www.example.com" +# Optional: max bytes buffered when a response is post-processed in full (HTML +# rewriting/injection) instead of streamed. Default 16 MiB; larger responses +# return 502. Raise for deployments serving very large publisher pages. +# max_buffered_body_bytes = 16777216 # 16 MiB + +# ----------------------------------------------------------------------------- +# REQUIRED — Edge Cookie (EC) identity +# ----------------------------------------------------------------------------- [ec] +# Secret used to derive EC identifiers. Must be >= 32 chars and non-placeholder +# in production (deploy validation rejects known placeholders). passphrase = "trusted-server-placeholder-secret" +# KV store that persists EC identity state. This is the physical store name +# bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's +# logical KV id is `trusted_server_kv`. ec_store = "ec_identity_store" +# Max concurrent partner pull-sync requests. pull_sync_concurrency = 3 -# cluster_trust_threshold = 10 -# cluster_recheck_secs = 3600 +# Optional cluster-heuristic tuning (defaults shown): +# cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users +# cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds -# Example partner configuration. Replace the token before validating/pushing. +# Optional identity partners (SSP/DSP/identity vendors). Each needs a real, +# non-placeholder api_token (>= 32 bytes) at deploy. Configure real partners via +# private config, not this template. # [[ec.partners]] # name = "Example Partner" # source_domain = "partner.example.com" -# OpenRTB agent type; vendor-specific values are supported (PAIR uses 571187). +# OpenRTB source.atype (default 3); vendor-specific values are supported +# (PAIR uses 571187). # openrtb_atype = 3 +# include this partner's UIDs in auction user.eids # bidstream_enabled = true # api_token = "replace-with-partner-api-token-32-bytes-minimum" -# batch_rate_limit = 60 -# pull_sync_enabled = false +# batch_rate_limit = 60 # max batch-sync requests/min (default 60) +# pull_sync_enabled = false # default false + + +# ============================================================================= +# OPTIONAL — Core features (disabled/omitted by default) +# ============================================================================= -# Custom headers to include in every response. +# Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] # X-Robots-Tag = "noindex" -[request_signing] -enabled = false -config_store_id = "app_config" -secret_store_id = "secrets" +# Sign outbound OpenRTB/API requests. These are platform management-API store +# IDs used for key-rotation WRITES; the runtime reads keys from fixed store names +# (`jwks_store` / `signing_keys`), not these. To keep signing OFF, leave this +# whole block commented out. If you uncomment it - even with `enabled = false` - +# both store IDs are required and must be REAL values: the key rotate/deactivate +# admin routes read them regardless of `enabled`, so deploy validation rejects +# the placeholders below (replace them before pushing). +# [request_signing] +# enabled = false +# config_store_id = "" +# secret_store_id = "" -[integrations.prebid] -enabled = false -server_url = "https://prebid.example.com/openrtb2/auction" -timeout_ms = 1000 -bidders = [] -debug = false -client_side_bidders = [] -# Runtime bundle metadata. Set these after running `ts prebid bundle` and uploading the bundle. +# First-party HTML/CSS rewriting controls. +# [rewrite] +# Domains left as-is (not proxied/rewritten). Supports "*.example.com" wildcards. +# exclude_domains = ["*.cdn.example.com"] + +# Tester-cookie endpoints: GET /_ts/set-tester sets ts-tester=true and +# GET /_ts/clear-tester clears it on publisher.cookie_domain. +# [tester_cookie] +# enabled = false + +# Consent forwarding. All values below are the defaults — uncomment to override. +# [consent] +# mode = "interpreter" # "interpreter" (decode + forward) or "proxy" (raw passthrough) +# check_expiration = true # check TCF consent freshness +# max_consent_age_days = 395 # max age before consent is treated as expired (~13 months) +# +# [consent.gdpr] +# applies_in = ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"] +# +# [consent.us_states] +# privacy_states = ["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"] +# +# [consent.us_privacy_defaults] +# notice_given = true # has the publisher shown CCPA notice? +# lspa_covered = false # is the publisher subject to LSPA? +# gpc_implies_optout = true # should Sec-GPC: 1 trigger opt-out? +# +# [consent.conflict_resolution] +# mode = "restrictive" # "restrictive" | "newest" | "permissive" +# freshness_threshold_days = 30 + +# Proxy behavior and first-party asset routing. +# [proxy] +# Verify TLS certs when proxying to HTTPS origins (default true; false only for +# local self-signed dev). +# certificate_check = true +# Allowlist for first-party proxy redirect destinations (SSRF guard). Supports +# exact ("example.com") and wildcard ("*.example.com", also matches apex). +# REQUIRED to include the Prebid external_bundle_url host when Prebid is enabled. +# allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] +# +# Route first-party asset paths to a different backend origin. Longest matching +# prefix wins; only GET/HEAD participate; query string is preserved. +# [[proxy.asset_routes]] +# prefix = "/.image/" +# origin_url = "https://assets.example.com" +# Optional path rewrite before sending upstream (the prefix must match what the +# pattern expects — here both use `/.image/`): +# path_pattern = "^/\\.image/(.*)/[^/]+\\.([^/.]+)$" +# target_path = "/image/upload/$1.$2" +# +# Optional S3 SigV4 auth for a private origin: +# [proxy.asset_routes.auth] +# type = "s3_sigv4" +# region = "us-east-1" +# origin_query = "strip" +# secret_store = "s3-auth" +# access_key_id = "access_key_id" +# secret_access_key = "secret_access_key" +# +# Optional Fastly Image Optimizer for the route (references a profile_set below): +# [proxy.asset_routes.image_optimizer] +# enabled = true +# region = "us_east" +# profile_set = "default_images" + +# Reusable Fastly Image Optimizer profile sets referenced by asset routes. +# Supported IO params are a strict subset: quality, resize-filter, format, +# width, height, crop. Keep production profile tables in private config. +# [image_optimizer.profile_sets.default_images] +# base_params = "quality=70&resize-filter=bicubic" +# default_profile = "default" +# unknown_profile = "use_default" # "use_default" or "reject" +# profile_param = "profile" +# +# [image_optimizer.profile_sets.default_images.profiles] +# default = "width=1920" +# thumbnail = "width=150&crop=1:1,smart" +# medium = "format=auto&width=828" + +# Server-side auction. Provider/mediator names must match enabled integrations. +# [auction] +# enabled = false +# providers = ["prebid"] +# mediator = "adserver_mock" # optional mediator integration +# timeout_ms = 2000 +# Rewrite sanitized winning-bid creative HTML to first-party endpoints (default +# true). Set false to skip proxy/click-URL conversion and creative TSJS +# injection; sanitization always still applies. Restore true before rolling back +# to an older binary that rejects unknown fields. +# rewrite_creatives = true +# Context keys the JS client may forward into auction requests (allowlist; +# empty blocks all). +# allowed_context_keys = ["permutive_segments"] + +# Server-side ad slot templates + creative-opportunity auction. +# [creative_opportunities] +# gam_network_id = "123456789" +# price_granularity = "dense" +# FCP is not affected by this value — body content above has already +# streamed and painted before the hold begins. What this caps is the slip on +# DOMContentLoaded and window.load. 500 ms is the recommended default; raise +# only if your SSPs need more headroom and analytics confirm the DCL slip is OK. +# auction_timeout_ms = 500 +# +# Slot templates. Add one block per slot below. (This is an array of tables, so +# the env overlay cannot set it - edit the `[[creative_opportunities.slot]]` +# blocks in TOML directly.) +# [[creative_opportunities.slot]] +# id = "leaderboard" +# gam_unit_path = "/123456789/leaderboard" +# div_id = "div-gpt-ad-leaderboard" +# page_patterns = ["/"] # glob syntax (not regex); "/" matches only the homepage +# formats = [{ width = 728, height = 90 }] + +# Direct Tinybird auction telemetry (off by default). When enabled, `api_host` +# is required and must be a bare host (no scheme or path). +# [tinybird] +# enabled = true +# api_host = "api.us-east.tinybird.example" # required when enabled; host only +# secret_store = "ts_secrets" # Secret Store holding the append token +# auction_dataset = "auction_events" # Events API datasource name +# auction_token_secret = "tinybird_auction_append_token" # Secret Store key for the token + +# Debug endpoints (all default false — never enable in production). +# [debug] +# Exposes GET /_ts/debug/ja4 returning TLS/JA4 fingerprint details. Disable +# after investigation; it reflects data browser JS cannot normally read. +# ja4_endpoint_enabled = false +# Injects a `` HTML comment before `` dumping a +# redacted per-provider auction result (pipeline stats plus every provider +# response, each bid's creative previewed). Visible in page source and injects +# bounded raw SSP HTML — never enable in production. +# auction_html_comment = false + + +# ============================================================================= +# OPTIONAL — Integrations +# ============================================================================= +# Every integration is off by default. Most are fully commented out; uncomment a +# block and set `enabled = true` to activate it. Four (gpt, didomi, datadome, +# google_tag_manager) are kept as active `enabled = false` stubs so the `ts audit` +# CLI can flip them in place — leave those sections present. Integrations whose +# `enabled` defaults to true (prebid, permutive, lockr, ...) still stay OFF while +# their section is commented out. Required fields are noted per block. +# ============================================================================= + +# Prebid Server-side auction + first-party Prebid.js bundle. +# When enabled: `server_url` is required, and `external_bundle_url` is required +# (its host must be listed in [proxy].allowed_domains). +# [integrations.prebid] +# enabled = true +# server_url = "https://prebid.example.com/openrtb2/auction" +# timeout_ms = 1000 +# bidders = [] +# debug = false +# client_side_bidders = [] # bidders running via native Prebid.js adapters +# Runtime bundle metadata — set after running `ts prebid bundle` and uploading: # external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" # external_bundle_sha256 = "" # external_bundle_sri = "" - -[integrations.prebid.bundle] -adapters = ["rubicon"] +# Per-bidder / per-zone param overrides (canonical rule form): +# [[integrations.prebid.bid_param_override_rules]] +# when.bidder = "examplebidder" +# when.zone = "header" +# set = { placementId = "_abc" } +# +# Bundle build inputs consumed by the `ts prebid bundle` CLI (not the runtime): +# [integrations.prebid.bundle] +# adapters = ["rubicon"] # user_id_modules = ["sharedIdSystem"] -[integrations.nextjs] -enabled = false -rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] -max_combined_payload_bytes = 10485760 +# Next.js first-party rewriting for App Router / RSC payloads. +# [integrations.nextjs] +# enabled = true +# rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] +# max_combined_payload_bytes = 10485760 # 10 MiB -[integrations.testlight] -enabled = false -endpoint = "https://testlight.example.com/openrtb2/auction" -timeout_ms = 1200 -rewrite_scripts = true +# Testlight OpenRTB test integration. `endpoint` required when enabled. +# [integrations.testlight] +# enabled = true +# endpoint = "https://testlight.example.com/openrtb2/auction" +# timeout_ms = 1200 +# rewrite_scripts = true +# Didomi CMP SDK/API first-party proxy. Kept active but disabled so `ts audit` +# can flip `enabled` to true when Didomi is detected on the audited page. [integrations.didomi] enabled = false -sdk_origin = "https://sdk.example.com" -api_origin = "https://api.example.com" +# sdk_origin = "https://sdk.example.com" +# api_origin = "https://api.example.com" -[integrations.sourcepoint] -enabled = false -rewrite_sdk = true -cdn_origin = "https://cdn.example.com" -cache_ttl_seconds = 3600 +# Sourcepoint CMP first-party proxy. +# `cdn_origin` is intentionally omitted: the upstream CDN origin is pinned to +# Sourcepoint's own host and is not operator-selectable. Leave it unset so the +# validated default applies — overriding it with any other host fails config +# validation. +# [integrations.sourcepoint] +# enabled = true +# rewrite_sdk = true +# cache_ttl_seconds = 3600 +# auth_cookie_name = "sp_auth" # optional: forward a custom authCookie upstream -[integrations.permutive] -enabled = false -organization_id = "" -workspace_id = "" -project_id = "" -api_endpoint = "https://api.example.com" -secure_signals_endpoint = "https://secure-signals.example.com" +# Osano consent management (proxy toggle only). +# [integrations.osano] +# enabled = true -[integrations.lockr] -enabled = false -app_id = "" -api_endpoint = "https://identity.example.com" -sdk_url = "https://identity.example.com/trusted-server.js" -cache_ttl_seconds = 3600 -rewrite_sdk = true +# Permutive DMP. `organization_id` and `workspace_id` required when enabled. +# [integrations.permutive] +# enabled = true +# organization_id = "your-permutive-organization-id" # required (non-empty) +# workspace_id = "your-permutive-workspace-id" # required (non-empty) +# project_id = "your-permutive-project-id" +# api_endpoint = "https://api.example.com" +# secure_signals_endpoint = "https://secure-signals.example.com" +# lockr identity SDK. `app_id` required when enabled. +# [integrations.lockr] +# enabled = true +# app_id = "your-lockr-app-id" # required (non-empty) +# api_endpoint = "https://identity.example.com" +# sdk_url = "https://identity.example.com/trusted-server.js" +# cache_ttl_seconds = 3600 +# rewrite_sdk = true + +# DataDome bot protection. Proxies tags.js + signal-collection API first-party. +# Kept active but disabled so `ts audit` can flip `enabled` when detected. [integrations.datadome] enabled = false -sdk_origin = "https://sdk.example.com" -api_origin = "https://api.example.com" -cache_ttl_seconds = 3600 -rewrite_sdk = true +# sdk_origin = "https://sdk.example.com" +# api_origin = "https://api.example.com" +# cache_ttl_seconds = 3600 +# rewrite_sdk = true +# Server-side Protection API validation (fails open on timeout/error): +# enable_protection = false +# server_side_key_secret_store = "ts_secrets" +# server_side_key_secret_name = "datadome_server_side_key" +# protection_api_origin = "https://api.example.com" +# timeout_ms = 1500 +# protection_excluded_methods = ["OPTIONS"] +# Client-side tag auto-injection (emits only when client_side_key is non-empty): +# client_side_key = "" +# inject_client_side_tag = true +# client_side_tag_url = "/integrations/datadome/tags.js" +# Google Publisher Tag (GPT) first-party proxy. Kept active but disabled so +# `ts audit` can flip `enabled` to true when GPT is detected. [integrations.gpt] enabled = false -script_url = "https://ads.example.com/gpt.js" -cache_ttl_seconds = 3600 -rewrite_script = true +# script_url = "https://ads.example.com/gpt.js" +# cache_ttl_seconds = 3600 +# rewrite_script = true -[integrations.gpt_diagnostics] -enabled = false +# GPT runtime diagnostics browser overlay. Optional and enabled manually (not +# flipped by `ts audit`); serves a diagnostics module gated behind an activation +# query param + session cookie. +# [integrations.gpt_diagnostics] +# enabled = true -[proxy] -# certificate_check = true -# Required for integrations.prebid.external_bundle_url and first-party proxy redirects. -# allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] - -[auction] -enabled = false -# Defaults to true. Keep this leaf present when using the EdgeZero v0.0.4 -# environment override. Set false to return sanitized but unre-written winning-bid -# adm, skipping proxy/click URL conversion and creative TSJS injection. -# Sanitization is always applied. Restore and push true before an older-binary rollback. -rewrite_creatives = true -providers = [] -timeout_ms = 2000 -allowed_context_keys = [] - -[integrations.aps] -enabled = false -pub_id = "your-aps-publisher-id" -endpoint = "https://aps.example.com/e/dtb/bid" -timeout_ms = 1000 +# Amazon Publisher Services (APS/TAM). `pub_id` required when enabled. +# [integrations.aps] +# enabled = true +# pub_id = "your-aps-publisher-id" +# endpoint = "https://aps.example.com/e/dtb/bid" +# timeout_ms = 1000 +# Google Tag Manager first-party proxy. Kept active but disabled so `ts audit` +# can fill container_id and flip `enabled` when GTM is detected. `container_id` +# is required when this integration is actually enabled. [integrations.google_tag_manager] enabled = false -container_id = "GTM-EXAMPLE" -upstream_url = "https://tags.example.com" - -[integrations.adserver_mock] -enabled = false -endpoint = "https://adserver.example.com/mediate" -timeout_ms = 1000 +# Invalid placeholder on purpose: enabling GTM without a real GTM-XXXXXX id +# fails validation. `ts audit` overwrites this when it detects a real container. +container_id = "GTM-REPLACE-ME" +# upstream_url = "https://tags.example.com" -[integrations.adserver_mock.context_query_params] -example_segments = "segments" - -[debug] -ja4_endpoint_enabled = false - -# Inject a `` HTML comment before `` dumping a -# redacted per-provider auction result: pipeline stats plus every provider -# response, with each bid's creative previewed (not the full markup) and -# provider metadata filtered to a fail-closed allowlist. -# Visible in page source and injects (bounded) raw HTML from SSPs. NEVER enable -# in production. -auction_html_comment = false - -[creative_opportunities] -gam_network_id = "123456789" -# FCP is not affected by this value — body content above has already -# streamed and painted before the hold begins. What this caps is the slip on -# DOMContentLoaded and window.load. Worst case: a cache-hit page where origin -# drains in <50 ms but the auction runs to the limit. 500 ms is the recommended -# default; raise only if your SSPs need more headroom and your analytics confirm -# the DCL slip is acceptable. -auction_timeout_ms = 500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS -price_granularity = "dense" - -# No slot templates are enabled in the checked-in default config. Add -# `[[creative_opportunities.slot]]` entries via private config or override the -# entire array via: -# TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","gam_unit_path":"...",...}]' +# Mock ad server used for auction mediation in dev/testing. +# [integrations.adserver_mock] +# enabled = true +# endpoint = "https://adserver.example.com/mediate" +# timeout_ms = 1000 +# Map auction context keys to mediation URL query params: +# [integrations.adserver_mock.context_query_params] +# example_segments = "segments"