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"));
}
}
}
76 changes: 76 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,82 @@ 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:?}"
);
}

#[test]
fn deploy_validation_allows_disabled_request_signing_with_placeholder_store_ids() {
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(),
});

validate_settings_for_deploy(&settings)
.expect("should allow placeholder store ids while request signing is disabled");
}

#[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_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
24 changes: 23 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::{json, Value as 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))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — APS validation runs even when the integration resolves to disabled

enabled defaults to false, but IntegrationSettings::get_typed only short-circuits raw configs that explicitly contain enabled = false. Otherwise it calls config.validate() before checking config.is_enabled(). Consequently, an APS section that omits enabled but contains this documented placeholder now fails ts config validate even though APS resolves to disabled. This is a backward-incompatible config regression and can prevent application-state construction after an upgrade.

Please check the resolved is_enabled() value before field validation, while retaining the explicit-false fast path, and add regression coverage for APS and adserver_mock sections with omitted enabled.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Whitespace-only APS publisher IDs pass the new non-empty check

length(min = 1) counts whitespace, while validate_aps_pub_id trims only for comparison with the reserved placeholder and never rejects an empty trimmed value. I confirmed that enabled APS with pub_id = " " passes ts config validate, leaving the invalid value to fail upstream.

Please reject pub_id.trim().is_empty() in the custom validator and test both empty and whitespace-only values through enabled deploy validation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — A whitespace-only pub_id still passes deploy validation. length(min = 1) counts the spaces, while validate_aps_pub_id trims only for the placeholder comparison. An enabled APS integration can therefore start with an unusable publisher ID.

Please reject pub_id.trim().is_empty() in the custom validator and add a whitespace-only regression test.

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,26 @@ impl Default for ApsConfig {
}
}

/// Validator for [`ApsConfig::pub_id`]: rejects the known template placeholder
/// publisher ID. Non-emptiness is enforced by the built-in `length` validator;
/// 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> {
if ApsConfig::PUB_ID_PLACEHOLDERS
.iter()
.any(|placeholder| placeholder.eq_ignore_ascii_case(pub_id.trim()))
{
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 @@ -677,6 +673,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
55 changes: 44 additions & 11 deletions crates/trusted-server-core/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::Duration;

use async_trait::async_trait;
Expand All @@ -13,6 +13,7 @@ use edgezero_core::body::Body as EdgeBody;
use error_stack::{Report, ResultExt};
use http::header::HeaderValue;
use http::{header, Method, StatusCode};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use url::{Url, Url as ParsedUrl};
Expand Down Expand Up @@ -99,6 +100,7 @@ pub struct PrebidIntegrationConfig {
#[serde(default)]
pub account_id: Option<String>,
#[serde(default = "default_timeout_ms")]
#[validate(range(min = 1, max = 60000))]
pub timeout_ms: u32,
#[serde(
default = "default_bidders",
Expand Down Expand Up @@ -129,7 +131,10 @@ pub struct PrebidIntegrationConfig {
pub external_bundle_url: Option<String>,
/// 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<String>,
/// Optional browser Subresource Integrity value for the first-party script.
#[serde(default)]
Expand Down Expand Up @@ -332,15 +337,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<Regex> =
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 {
Expand Down Expand Up @@ -2238,6 +2238,39 @@ mod tests {

use crate::consent::{ConsentContext, ConsentSource};
use crate::geo::GeoInfo;

#[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"));
}
}
use crate::html_processor::{create_html_processor, HtmlProcessorConfig};
use crate::integrations::{
AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry,
Expand Down
Loading
Loading