Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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