From 03fa117e2b826881126137819be7270132a89e06 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:19:38 -0800 Subject: [PATCH 1/2] Fix response header settings --- crates/common/src/settings.rs | 84 ++++++++++++++++++++++++++++++++++- docs/guide/configuration.md | 8 +++- trusted-server.toml | 6 ++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index 6b2d4ca8..3edba81d 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -310,7 +310,7 @@ pub struct Settings { #[serde(default, deserialize_with = "vec_from_seq_or_map")] #[validate(nested)] pub handlers: Vec, - #[serde(default)] + #[serde(default, deserialize_with = "map_from_obj_or_str")] pub response_headers: HashMap, pub request_signing: Option, #[serde(default)] @@ -423,6 +423,49 @@ fn validate_path(value: &str) -> Result<(), ValidationError> { // This lets env vars like TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS__0=smartadserver work, which the config env source // represents as an object {"0": "value"} rather than a sequence. Also supports string inputs that are // JSON arrays or comma-separated values. +/// Deserializes a `HashMap` from either: +/// - A TOML table / JSON object (standard deserialization) +/// - A JSON string (e.g. from env var: `'{"X-Robots-Tag": "noindex"}'`) +/// +/// This allows setting response headers via environment variables while +/// preserving header name casing and hyphens. +pub(crate) fn map_from_obj_or_str<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let v = JsonValue::deserialize(deserializer)?; + match v { + JsonValue::Object(map) => map + .into_iter() + .map(|(k, v)| { + let val = match v { + JsonValue::String(s) => s, + other => other.to_string(), + }; + Ok((k, val)) + }) + .collect(), + JsonValue::String(s) => { + let txt = s.trim(); + if txt.starts_with('{') { + serde_json::from_str::>(txt) + .map_err(serde::de::Error::custom) + } else { + Err(serde::de::Error::custom( + "expected JSON object string for response_headers, e.g. '{\"X-Header\": \"value\"}'", + )) + } + } + JsonValue::Null => Ok(HashMap::new()), + other => Err(serde::de::Error::custom(format!( + "expected object or JSON string for response_headers, got {}", + other + ))), + } +} + pub(crate) fn vec_from_seq_or_map<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -792,6 +835,45 @@ mod tests { ); } + #[test] + fn test_response_headers_override_with_json_env() { + let toml_str = crate_test_settings_str(); + let env_key = format!( + "{}{}RESPONSE_HEADERS", + ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, + ); + + let origin_key = format!( + "{}{}PUBLISHER{}ORIGIN_URL", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + temp_env::with_var( + origin_key, + Some("https://origin.test-publisher.com"), + || { + temp_env::with_var( + env_key, + Some(r#"{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}"#), + || { + let settings = Settings::from_toml(&toml_str) + .expect("Settings should parse with JSON response_headers env"); + assert_eq!(settings.response_headers.len(), 2); + assert_eq!( + settings.response_headers.get("X-Robots-Tag"), + Some(&"noindex".to_string()) + ); + assert_eq!( + settings.response_headers.get("X-Custom-Header"), + Some(&"custom value".to_string()) + ); + }, + ); + }, + ); + } + #[test] fn test_settings_extra_fields() { let toml_str = crate_test_settings_str() + "\nhello = 1"; diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d308e778..d193e856 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -434,10 +434,16 @@ Cache-Control = "public, max-age=3600" **Environment Override**: +Use a JSON object to preserve header name casing and hyphens: + ```bash -TRUSTED_SERVER__RESPONSE_HEADERS__X_CUSTOM_HEADER="custom value" +TRUSTED_SERVER__RESPONSE_HEADERS='{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}' ``` +::: tip Why JSON? +Individual env var keys like `TRUSTED_SERVER__RESPONSE_HEADERS__X_CUSTOM_HEADER` lose hyphens and casing (becoming `x_custom_header`). The JSON format preserves exact header names. +::: + **Use Cases**: - Custom tracking headers diff --git a/trusted-server.toml b/trusted-server.toml index cdad1f50..5ad18888 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -25,6 +25,9 @@ template = "{{ client_ip }}:{{ user_agent }}:{{ accept_language }}:{{ accept_enc # Allows publishers to include tags such as X-Robots-Tag: noindex # [response_headers] # X-Custom-Header = "custom header value" +# +# Or via environment variable (JSON preserves header name casing and hyphens): +# TRUSTED_SERVER__RESPONSE_HEADERS='{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}' # Request Signing Configuration # Enable signing of OpenRTB requests and other API calls @@ -38,10 +41,9 @@ enabled = true server_url = "http://68.183.113.79:8000" timeout_ms = 1000 bidders = ["kargo", "rubicon", "appnexus", "openx"] -auto_configure = false debug = false # debug_query_params = "" -# script_handler = "/prebid.js" +# script_patterns = ["/prebid.js"] [integrations.nextjs] enabled = false From 84055f1c6e5b00ece22853afa00d9817013d4ea5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:28:15 -0800 Subject: [PATCH 2/2] Improved error message and test --- crates/common/src/settings.rs | 47 +++++++++++++---------------------- 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index 3edba81d..5963b602 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -425,10 +425,10 @@ fn validate_path(value: &str) -> Result<(), ValidationError> { // JSON arrays or comma-separated values. /// Deserializes a `HashMap` from either: /// - A TOML table / JSON object (standard deserialization) -/// - A JSON string (e.g. from env var: `'{"X-Robots-Tag": "noindex"}'`) +/// - A JSON string (e.g. from env var: `'{"Key": "value"}'`) /// -/// This allows setting response headers via environment variables while -/// preserving header name casing and hyphens. +/// This allows setting map fields via environment variables while +/// preserving key casing and special characters like hyphens. pub(crate) fn map_from_obj_or_str<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -454,14 +454,13 @@ where .map_err(serde::de::Error::custom) } else { Err(serde::de::Error::custom( - "expected JSON object string for response_headers, e.g. '{\"X-Header\": \"value\"}'", + "expected JSON object string, e.g. '{\"Key\": \"value\"}'", )) } } JsonValue::Null => Ok(HashMap::new()), other => Err(serde::de::Error::custom(format!( - "expected object or JSON string for response_headers, got {}", - other + "expected object or JSON string, got {other}", ))), } } @@ -843,32 +842,20 @@ mod tests { ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, ); - let origin_key = format!( - "{}{}PUBLISHER{}ORIGIN_URL", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); temp_env::with_var( - origin_key, - Some("https://origin.test-publisher.com"), + env_key, + Some(r#"{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}"#), || { - temp_env::with_var( - env_key, - Some(r#"{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}"#), - || { - let settings = Settings::from_toml(&toml_str) - .expect("Settings should parse with JSON response_headers env"); - assert_eq!(settings.response_headers.len(), 2); - assert_eq!( - settings.response_headers.get("X-Robots-Tag"), - Some(&"noindex".to_string()) - ); - assert_eq!( - settings.response_headers.get("X-Custom-Header"), - Some(&"custom value".to_string()) - ); - }, + let settings = Settings::from_toml(&toml_str) + .expect("Settings should parse with JSON response_headers env"); + assert_eq!(settings.response_headers.len(), 2); + assert_eq!( + settings.response_headers.get("X-Robots-Tag"), + Some(&"noindex".to_string()) + ); + assert_eq!( + settings.response_headers.get("X-Custom-Header"), + Some(&"custom value".to_string()) ); }, );