Skip to content
Open
30 changes: 29 additions & 1 deletion crates/trusted-server-core/src/auction_config_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,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)
Expand Down Expand Up @@ -79,3 +81,29 @@ impl AuctionConfig {
self.mediator.is_some()
}
}

#[cfg(test)]
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"));
}
}
}
165 changes: 165 additions & 0 deletions crates/trusted-server-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,171 @@ 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: "<management-config-store-id>".to_string(),
secret_store_id: "<management-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: "<management-config-store-id>".to_string(),
secret_store_id: "<management-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_aps_pub_id() {
for (label, pub_id) in [("empty", ""), ("whitespace-only", " ")] {
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 APS pub_id when enabled");

assert!(
format!("{err:?}").contains("aps"),
"should mention the APS integration for {label} pub_id: {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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion crates/trusted-server-core/src/integrations/aps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -201,6 +201,7 @@ pub struct ApsConfig {

/// APS publisher ID (accepts both string and integer from config)
#[serde(deserialize_with = "deserialize_pub_id")]
#[validate(length(min = 1), custom(function = validate_aps_pub_id))]
Comment thread
aram356 marked this conversation as resolved.
Comment thread
aram356 marked this conversation as resolved.
Comment thread
aram356 marked this conversation as resolved.
pub pub_id: String,

/// APS API endpoint
Expand All @@ -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,
}

Expand Down Expand Up @@ -285,6 +287,35 @@ impl Default for ApsConfig {
}
}

/// Validator for [`ApsConfig::pub_id`]: rejects blank publisher IDs and the
/// known template placeholder. The built-in `length` validator counts
/// whitespace, so a whitespace-only `pub_id` has to be rejected here. 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 pub_id = pub_id.trim();

if pub_id.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 ApsConfig::PUB_ID_PLACEHOLDERS
.iter()
.any(|placeholder| placeholder.eq_ignore_ascii_case(pub_id))
{
return Err(ValidationError::new("aps_pub_id_placeholder"));
}
Ok(())
}

impl ApsConfig {
/// Reserved example `pub_id` values from the config template that must not
/// be deployed while APS is enabled.
pub const PUB_ID_PLACEHOLDERS: &[&str] = &["your-aps-publisher-id"];
}

impl IntegrationConfig for ApsConfig {
fn is_enabled(&self) -> bool {
self.enabled
Expand Down
51 changes: 40 additions & 11 deletions crates/trusted-server-core/src/integrations/google_tag_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.googletagmanager.com>).
#[serde(default = "default_upstream")]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading