From 765d6a33b1334823a8651825c6b222c116eb9e78 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:07:46 -0700 Subject: [PATCH] Document the config template and harden config validation Config-surface cleanup closing #869 and #871. 1. Document `trusted-server.example.toml` and restore supported sections. Required minimum stays active and documented ([[handlers]], [publisher], [ec]); every optional section/integration is commented out with a one-line description. Documented blocks are push-ready: required-when-enabled fields use self-describing fill-me-in values. A header caveat notes the env overlay only replaces existing scalar leaves. 2. Reject fail-open placeholder config at deploy validation: publisher domain/cookie_domain/origin_url example defaults (origin compared on parsed host so :443 / trailing slash cannot bypass), request_signing store IDs (rejected whenever the block is present, incl. surrounding whitespace), and the reserved APS pub_id placeholder plus blank/whitespace-padded pub_ids. 3. Replace hand-rolled GTM/Prebid validators with the built-in `regex` validator. 4. Bound `timeout_ms` with the built-in `range` validator for aps, prebid, adserver_mock, and auction. Field validation runs only once an integration resolves to enabled, so an omitted `enabled` no longer rejects documented placeholders in disabled sections. Includes mirror unit tests for the placeholder helpers and a regression test that uncomments each documented block and asserts it validates. --- .../src/auction_config_types.rs | 25 +- crates/trusted-server-core/src/config.rs | 286 ++++++++ .../src/integrations/adserver_mock.rs | 1 + .../src/integrations/aps.rs | 1 + .../src/integrations/google_tag_manager.rs | 51 +- .../src/integrations/prebid.rs | 55 +- crates/trusted-server-core/src/settings.rs | 205 +++++- scripts/template-cache-local-test.sh | 5 +- trusted-server.example.toml | 642 ++++++++++++------ 9 files changed, 1042 insertions(+), 229 deletions(-) diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 27b62b11b..af52b7ad2 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 @@ -52,6 +53,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) @@ -132,6 +134,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 creative_processing_defaults() { let config: AuctionConfig = diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index e74ef4150..920ef9ff4 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -267,6 +267,108 @@ formats = [{ width = 300, height = 250 }] 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`, `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(); @@ -357,6 +459,190 @@ 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_blank_aps_account_id() { + // `deserialize_account_id` trims then rejects an empty result, so blank + // and whitespace-only ids fail at parse time. + for (label, account_id) in [("empty", ""), ("whitespace-only", " ")] { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "aps", + &serde_json::json!({ + "enabled": true, + "account_id": account_id, + "endpoint": "https://aps.example.com/e/pb/bid" + }), + ) + .expect("should insert APS config"); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject blank APS account_id when enabled"); + + assert!( + format!("{err:?}").contains("aps"), + "should mention the APS integration for {label} account_id: {err:?}" + ); + } + } + + #[test] + fn deploy_validation_normalizes_padded_aps_account_id() { + // Surrounding whitespace is normalized (trimmed) at deserialization, so + // a padded-but-otherwise-valid id deploys and reaches APS trimmed. + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "aps", + &serde_json::json!({ + "enabled": true, + "account_id": " example-account ", + "endpoint": "https://aps.example.com/e/pb/bid" + }), + ) + .expect("should insert APS config"); + + validate_settings_for_deploy(&settings) + .expect("should accept a padded-but-valid APS account_id (trimmed at deserialization)"); + } + + #[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 ba3f82776..1d6527934 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -45,6 +45,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 4fed9278d..c47f62d59 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -130,6 +130,7 @@ pub struct ApsConfig { pub endpoint: String, /// Timeout in milliseconds. #[serde(default = "default_timeout_ms")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, /// Whether to include the APS HTTP exchange in auction response metadata. /// 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 0dfb5906f..162e9eb8c 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 c9b3f5ded..d0cf37275 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}; @@ -211,6 +212,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", @@ -241,7 +243,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)] @@ -519,15 +524,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 { @@ -2721,6 +2721,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 1e3444ff0..c3a82c31f 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -123,6 +123,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 @@ -232,6 +277,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!( @@ -240,10 +294,6 @@ impl IntegrationSettings { }) })?; - if !config.is_enabled() { - return Ok(None); - } - Ok(Some(config)) } } @@ -627,6 +677,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 } @@ -2618,6 +2696,7 @@ pub struct Settings { #[validate(nested)] pub rewrite: Rewrite, #[serde(default)] + #[validate(nested)] pub auction: AuctionConfig, #[serde(default)] pub consent: ConsentConfig, @@ -2825,6 +2904,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(()); @@ -4401,6 +4505,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!( @@ -4427,6 +4604,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/scripts/template-cache-local-test.sh b/scripts/template-cache-local-test.sh index 96d6cb7ff..cc7e9eb87 100755 --- a/scripts/template-cache-local-test.sh +++ b/scripts/template-cache-local-test.sh @@ -181,7 +181,10 @@ src, out, mode, port = sys.argv[1:5] s = open(src).read() s = s.replace('origin_url = "https://origin.example.com"', f'origin_url = "http://127.0.0.1:{port}"', 1) -# The example config ships placeholders that validation rejects outright. +# The example config ships placeholders that validation rejects outright, +# including the reserved publisher domain/cookie_domain. +s = s.replace('domain = "example.com"', 'domain = "local-harness.example"', 1) +s = s.replace('cookie_domain = ".example.com"', 'cookie_domain = ".local-harness.example"', 1) s = s.replace('password = "replace-with-admin-password-32-bytes"', 'password = "local-harness-admin-password-not-a-real-one"', 1) s = s.replace('proxy_secret = "change-me-proxy-secret"', diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 5bbdfb857..99cc94212 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -1,132 +1,215 @@ +# ============================================================================= +# 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 - -# Custom headers to include in every response. -# [response_headers] -# X-Robots-Tag = "noindex" +# batch_rate_limit = 60 # max batch-sync requests/min (default 60) +# pull_sync_enabled = false # default false -[request_signing] -enabled = false -config_store_id = "app_config" -secret_store_id = "secrets" -[integrations.prebid] -enabled = false -server_url = "https://prebid.example.com/openrtb2/auction" -timeout_ms = 1000 -bidders = [] -debug = false -client_side_bidders = [] -# Keep selected GAM inventory out of Trusted Server's Prebid refresh auctions. -# Matching slots still refresh through GAM. -# excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] -# Runtime bundle metadata. Set these after running `ts prebid bundle` and uploading the bundle. -# external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" -# external_bundle_sha256 = "" -# external_bundle_sri = "" - -[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 - -[integrations.testlight] -enabled = false -endpoint = "https://testlight.example.com/openrtb2/auction" -timeout_ms = 1200 -rewrite_scripts = true +# ============================================================================= +# OPTIONAL — Core features (disabled/omitted by default) +# ============================================================================= -[integrations.didomi] -enabled = false -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 - -[integrations.permutive] -enabled = false -organization_id = "" -workspace_id = "" -project_id = "" -api_endpoint = "https://api.example.com" -secure_signals_endpoint = "https://secure-signals.example.com" +# Custom headers added to every response (e.g. X-Robots-Tag: noindex). +# [response_headers] +# X-Robots-Tag = "noindex" -[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 +# 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.datadome] -enabled = false -sdk_origin = "https://sdk.example.com" -api_origin = "https://api.example.com" -cache_ttl_seconds = 3600 -rewrite_sdk = true +# First-party HTML/CSS rewriting controls. +# [rewrite] +# Domains left as-is (not proxied/rewritten). Supports "*.example.com" wildcards. +# exclude_domains = ["*.cdn.example.com"] -[integrations.gpt] -enabled = false -# Keep this leaf present so the environment override can apply; the overlay -# cannot create a missing configuration leaf. Attribution stays off until enabled. -gam_attribution_enabled = false -script_url = "https://ads.example.com/gpt.js" -cache_ttl_seconds = 3600 -rewrite_script = true +# 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 -[integrations.gpt_diagnostics] -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. Kept active with defaults. [proxy] +# Verify TLS certs when proxying to HTTPS origins (default true; false only for +# local self-signed dev). # certificate_check = true -# Required for integrations.prebid.external_bundle_url and first-party proxy redirects. +# 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"] - -# Static/rehosted asset cache policies are operator-controlled. Disabled rules -# do not match, and matcher/policy validation is deferred until they are enabled; -# IDs must still be nonempty and unique. Keep rules disabled unless the matched -# publisher paths are known content-addressed. +# +# 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" + +# Operator-controlled cache header policies for static/rehosted assets. Disabled +# rules never match, and matcher/policy validation is deferred until a rule is +# enabled; IDs must be non-empty and unique. Keep rules disabled unless the +# matched publisher paths are known to be content-addressed. # [[cache.asset_rules]] # id = "nextjs-static" # enabled = false -# preset = "nextjs-static" +# preset = "nextjs-static" # built-in matcher/policy preset # visibility = "public" # browser_ttl_seconds = 31536000 # edge_ttl_seconds = 31536000 @@ -136,134 +219,76 @@ enabled = false # id = "publisher-fingerprinted-assets" # enabled = false # path_globs = ["/assets/**/*.js", "/assets/**/*.css", "/assets/**/*.png", "/assets/**/*.webp"] -# Immutable custom rules require an unambiguous fingerprint_style, either -# "hex" or "esbuild-base32". "vite-base64-url" is allowed only for -# non-immutable rules because ordinary filenames can match its shape. +# Immutable custom rules require an unambiguous fingerprint_style, either "hex" +# or "esbuild-base32". "vite-base64-url" is allowed only for non-immutable rules +# because ordinary filenames can match its shape. # fingerprint_style = "hex" # visibility = "public" # browser_ttl_seconds = 31536000 # edge_ttl_seconds = 31536000 # immutable = true +# Server-side auction. Provider/mediator names must match enabled integrations. +# Kept active with the creative-processing leaves present so the EdgeZero +# environment override can apply to them. [auction] enabled = false -# Defaults to true. Keep this leaf present when using the EdgeZero v0.0.4 -# environment override. Set false to return unre-written winning-bid adm, -# skipping proxy/click URL conversion and creative TSJS injection. -# Sanitization is controlled separately by `sanitize_creatives` below. -# Restore and push true before an older-binary rollback. +# Rewrite winning-bid creative HTML to first-party endpoints (default true). Set +# false to skip proxy/click-URL conversion and creative TSJS injection. +# Sanitization is controlled separately by `sanitize_creatives` below. Restore +# true before rolling back to an older binary that rejects unknown fields. rewrite_creatives = true # Strip executable markup (script/object/embed/form/...) from winning-bid adm, -# removing those elements together with their inner content. -# -# Defaults to false: executable markup is preserved rather than stripped. Note -# this is not "untouched" — with the default `rewrite_creatives = true` above, -# eligible URLs are still rewritten to first-party endpoints, bidder `` -# elements are removed, and the creative TSJS runtime is injected. -# -# Enable whenever creatives can render in a context that shares the publisher's -# origin — it is the primary defence there. -# -# Leave disabled when creatives render in a foreign-origin frame (for example the -# Prebid Universal Creative inside the ad server's iframe), where the markup cannot -# reach the publisher origin. Sanitization removes script-based creatives entirely, -# so enabling it on a script-heavy demand stack silently blanks those slots. +# removing those elements together with their inner content. Defaults to false +# (executable markup preserved). Note that with `rewrite_creatives = true` the +# adm is still not untouched: eligible URLs are rewritten, bidder `` +# elements are removed, and the creative TSJS runtime is injected. Enable it +# whenever creatives can render in a context that shares the publisher origin +# (its primary defence there); leave it off when creatives render in a +# foreign-origin frame (e.g. the Prebid Universal Creative inside the ad +# server's iframe), since it removes script-based creatives entirely and would +# blank slots on a script-heavy demand stack. sanitize_creatives = false providers = [] timeout_ms = 2000 +# mediator = "adserver_mock" # optional mediator integration +# Context keys the JS client may forward into auction requests (allowlist; +# empty blocks all). allowed_context_keys = [] -[integrations.aps] -enabled = false -account_id = "example-aps-account-id" -timeout_ms = 1000 -# Include raw APS request/response data in /auction metadata on test sites only. -debug = false -# Set both when the deployment hostname differs from APS-authorized inventory. -# inventory_domain = "publisher.example" -# inventory_page_origin = "https://www.publisher.example" -# Script creatives require separate security validation before opt-in. -allow_script_creatives = false - -[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 - -[integrations.adserver_mock.context_query_params] -example_segments = "segments" - -[debug] -ja4_endpoint_enabled = false - -# NEVER enable in production. Injects an auction dump before ``. -# "redacted" validates response metadata but still includes bid-level fields -# and creative previews; it is not a fully anonymized dump. -auction_html_comment = false - -[debug.auction_html_comment_options] -include_provider_responses = true -include_mediator_response = true -include_bids = true -# Subset of the fixed validated metadata keys shown in "redacted" (and, for -# these three keys, "upstream") mode. Any other key fails config load. -metadata_keys = ["error_type", "http_status", "message"] -# "redacted" (default), "upstream", or "full". -# "upstream" exposes six untyped provider diagnostic values that may contain -# request or identity data. "full" additionally exposes all response metadata -# and untruncated creatives. Never use either sensitive mode in production. -verbosity = "redacted" -# "compact" (default) or "pretty". Pretty formats only the outer dump; -# JSON request/response bodies remain strings exactly as captured. -format = "compact" - +# Server-side ad slot templates + creative-opportunity auction. Kept active. [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. 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" - -# Initial-page delivery mode. `inline` is the default and current production -# behaviour. `esi` is an opt-in Fastly Core Cache experiment. The shared template cache stores an inert -# comment; a cold miss validates the repaired ESI parser in a private working -# copy, while warm hits use the streaming byte seam. No fragment HTTP request or -# publisher-controlled ESI is allowed. -# This and the three cache-safety keys below belong in this -# [creative_opportunities] table. See docs/guide/configuration.md before enabling. +# 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 +# +# Initial-page delivery mode (spike/experimental). `inline` is the default and +# current production behaviour; `esi` is an opt-in Fastly Core Cache experiment +# storing an inert comment in a shared, reader-neutral template cache. See +# docs/guide/configuration.md before enabling. This and the three cache-safety +# keys below belong in this [creative_opportunities] table. # assembly_mode = "inline" - -# Request headers (other than Accept-Encoding, whose supported content coding TS -# decodes to identity before storage) that the origin may name in Vary. Values -# are keyed losslessly and the origin response is checked for drift before -# storage. Every emitted Vary name must be covered or storage is refused. Never -# include Cookie: shared templates must be reader-neutral. +# Request headers (besides Accept-Encoding) the origin may name in Vary. Every +# emitted Vary name must be covered here or template storage is refused. Never +# include Cookie — shared templates must be reader-neutral. # template_cache_vary = [ # "rsc", # "next-router-state-tree", # "next-router-prefetch", # "next-router-segment-prefetch", # ] - -# Safety ceiling for one reader-neutral shared template, in seconds. The origin must -# still authorize shared freshness; TS uses the smaller of that remaining edge -# freshness and this value. Defaults to 60. Valid range: 1 through 86400. -# template_cache_max_age_seconds = 1200 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__TEMPLATE_CACHE_MAX_AGE_SECONDS - -# Unsafe unless independently verified at the publisher origin. The default -# excludes every cookie-bearing request from the template cache. Set true only when origin HTML -# is byte-independent of Cookie; an origin Vary: Cookie is still refused. +# Safety ceiling (seconds) for one shared template; TS uses the smaller of this +# and the origin-authorized remaining edge freshness. Default 60; range 1-86400. +# template_cache_max_age_seconds = 1200 +# Unsafe unless independently verified: excludes cookie-bearing requests from the +# template cache by default. Set true only when origin HTML is byte-independent +# of Cookie; an origin `Vary: Cookie` is still refused. # origin_is_cookie_independent = false - +# # `gam_unit_path` may be a template. Supported placeholders: # {network_id} -> gam_network_id # {slot_id} -> the slot's id @@ -274,7 +299,7 @@ price_granularity = "dense" # behavior: verbatim path, or the default `//`. # # `section_root` is REQUIRED when any slot's template uses {section}. There is no -# default — the home-section name is publisher-specific. Must be [A-Za-z0-9_-]+. +# default - the home-section name is publisher-specific. Must be [A-Za-z0-9_-]+. # # `section_segment` is the 0-based index of the segment that names the section; # it defaults to 0 (the first segment). Set it to 1 for locale-prefixed URLs, so @@ -284,15 +309,20 @@ price_granularity = "dense" # still ships in the pushed config blob. # section_root = "home" # section_segment = 0 - -# 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":"...",...}]' +# +# 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 }] # # Example templated slot (one rule serves every section). Uncomment # `section_root` above when enabling it. Note that "/news/*" does not match -# "/news" — list the section landing page separately. +# "/news" - list the section landing page separately. # [[creative_opportunities.slot]] # id = "ad-header" # gam_unit_path = "/{network_id}/example/{section}" @@ -302,3 +332,213 @@ price_granularity = "dense" # "/news" -> /123456789/example/news # "/news/x" -> /123456789/example/news # "/reviews/y" -> /123456789/example/reviews + +# 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 `` auction dump before ``. NEVER enable +# in production: even the default "redacted" mode still includes bid-level fields +# and creative previews — it is not a fully anonymized dump. +# auction_html_comment = false +# +# Fine-grained control over the dump (all optional; defaults shown). +# [debug.auction_html_comment_options] +# include_provider_responses = true +# include_mediator_response = true +# include_bids = true +# Subset of the fixed validated metadata keys shown in "redacted" (and, for these +# three keys, "upstream") mode. Any other key fails config load. +# metadata_keys = ["error_type", "http_status", "message"] +# "redacted" (default), "upstream", or "full". "upstream" exposes six untyped +# provider diagnostic values that may contain request or identity data; "full" +# additionally exposes all response metadata and untruncated creatives. Never use +# either sensitive mode in production. +# verbosity = "redacted" +# "compact" (default) or "pretty"; pretty formats only the outer dump, JSON +# request/response bodies remain strings exactly as captured. +# format = "compact" + + +# ============================================================================= +# 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). Kept active but disabled. +[integrations.prebid] +enabled = false +server_url = "https://prebid.example.com/openrtb2/auction" +timeout_ms = 1000 +bidders = [] +debug = false +client_side_bidders = [] # bidders running via native Prebid.js adapters +# Keep selected GAM inventory out of Trusted Server's Prebid refresh auctions. +# Matching slots still refresh through GAM. +# excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] +# 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 = "" +# 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"] + +# 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 + +# 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" + +# 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 + +# Osano consent management (proxy toggle only). +# [integrations.osano] +# enabled = 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 +# 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" +# Temporary static-header bypass for an access-controlled STAGING environment +# only. A matching `x-ts-datadome-bypass` header skips server-side Protection +# API validation and is stripped before the origin sees it. The credential is +# loaded from the Secret Store at runtime (>= 32 bytes of high-entropy material). +# Never enable in production. +# [integrations.datadome.protection_test_bypass] +# enabled = true +# credential_secret_store = "ts_secrets" +# credential_secret_name = "datadome_protection_test_bypass" + +# 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 +# Kept as an active leaf so the environment override can apply; the overlay +# cannot create a missing configuration leaf. GAM attribution stays off until +# this is set true. +gam_attribution_enabled = false +# script_url = "https://ads.example.com/gpt.js" +# cache_ttl_seconds = 3600 +# rewrite_script = true + +# 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 + +# Amazon Publisher Services (APS/TAM) OpenRTB. `account_id` required when +# enabled (`pub_id` is accepted as a deserialization alias only). +# [integrations.aps] +# enabled = true +# account_id = "example-aps-account-id" # required (non-empty); your APS account +# endpoint = "https://aps.example.com/e/dtb/bid" +# timeout_ms = 1000 +# Include raw APS request/response data in /auction metadata on test sites only. +# debug = false +# Script creatives require separate security validation before opt-in. +# allow_script_creatives = false +# Set both when the deployment hostname differs from APS-authorized inventory. +# inventory_domain = "publisher.example" +# inventory_page_origin = "https://www.publisher.example" + +# 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 +# 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" + +# 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"