diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index f57714dcc..47f614387 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -443,7 +443,8 @@ pub struct EcPartner { } impl EcPartner { - /// Known partner API token placeholders that must not be used in deployments. + /// Known partner secret placeholders (`api_token` and `ts_pull_token`) that + /// must not be used in deployments. pub const API_TOKEN_PLACEHOLDERS: &[&str] = &[ "partner-api-token-32-bytes-minimum", "replace-with-partner-api-token-32-bytes-minimum", @@ -3089,6 +3090,16 @@ impl Settings { { insecure_fields.push(format!("ec.partners[{}].api_token", partner.source_domain)); } + if partner + .ts_pull_token + .as_ref() + .is_some_and(|token| EcPartner::is_placeholder_api_token(token.expose())) + { + insecure_fields.push(format!( + "ec.partners[{}].ts_pull_token", + partner.source_domain + )); + } } for handler in &self.handlers { if Handler::is_placeholder_password(handler.password.expose()) { @@ -5406,6 +5417,57 @@ source_domain = "partner.example.com" ); } + fn test_partner_with_pull_token(ts_pull_token: &str) -> EcPartner { + EcPartner { + name: "Test Partner".to_owned(), + source_domain: "partner.example.com".to_owned(), + openrtb_atype: EcPartner::default_openrtb_atype(), + bidstream_enabled: false, + api_token: None, + batch_rate_limit: EcPartner::default_batch_rate_limit(), + pull_sync_enabled: true, + pull_sync_url: Some("https://partner.example.com/sync".to_owned()), + pull_sync_allowed_domains: vec!["partner.example.com".to_owned()], + pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), + pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + ts_pull_token: Some(Redacted::new(ts_pull_token.to_owned())), + } + } + + #[test] + fn reject_placeholder_secrets_includes_partner_pull_tokens() { + let mut settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); + settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + settings.ec.partners = vec![test_partner_with_pull_token( + "partner-api-token-32-bytes-minimum", + )]; + + let err = settings + .reject_placeholder_secrets() + .expect_err("should reject placeholder partner pull token"); + assert!( + format!("{err:?}").contains("ec.partners[partner.example.com].ts_pull_token"), + "error should mention the partner pull token field" + ); + } + + #[test] + fn reject_placeholder_secrets_allows_realistic_partner_pull_token() { + let mut settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); + settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + settings.ec.partners = vec![test_partner_with_pull_token( + "unit-test-realistic-pull-sync-token-32-bytes-min", + )]; + + settings + .reject_placeholder_secrets() + .expect("should accept a realistic partner pull token"); + } + #[test] fn is_unusable_store_id_rejects_placeholders_empty_and_padded_values() { for placeholder in RequestSigning::STORE_ID_PLACEHOLDERS {