diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3316e42..801359bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Image Optimizer configuration now rejects profile and profile-set names within the same map that collide after trimming, even when their values match, instead of silently overwriting an entry. Empty names continue to be discarded. - Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`. The `?tsdebug=1` query parameter or `localStorage['tsdebug'] = '1'` intentionally enables `debug` logging across all bundles when creative installs, overriding any log level set earlier through `tsjs.setConfig` or `tsjs.log.setLevel`. Later calls to either API can change the level again. - `[auction].allowed_context_keys` now serializes in sorted, deduplicated order, so ESI template-cache fingerprints and `ts config diff`/`push` envelope hashes are stable across loads. Template fingerprints also sort object keys independently of `serde_json/preserve_order`. Existing envelopes may show a one-time allowlist reorder after upgrading; push once to settle it. The updated fingerprint format causes one template-cache miss per cached page after deployment. - TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and omission in valid envelopes retain inline-first stored fallback. A malformed envelope disables stored fallback for the entire slot, including independent direct demand left unusable after overrides. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 79f100e2f..e48f5c2f0 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -109,7 +109,9 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { D: Deserializer<'de>, { let mut settings = Settings::deserialize(deserializer)?; - settings.normalize_deserialized(); + settings + .normalize_deserialized() + .map_err(serde::de::Error::custom)?; Ok(Self { settings }) } } @@ -796,6 +798,79 @@ formats = [{ width = 300, height = 250 }] ); } + #[test] + fn app_config_image_optimizer_rejects_trimmed_key_collisions() { + for (section, first, second) in [ + ( + r#" + [image_optimizer.profile_sets.default_images] + default_profile = "medium" + [image_optimizer.profile_sets.default_images.profiles] + medium = "width=100" + " medium" = "width=200" + "#, + "medium", + " medium", + ), + ( + r#" + [image_optimizer.profile_sets.default_images.profiles] + default = "width=100" + [image_optimizer.profile_sets." default_images".profiles] + default = "width=200" + "#, + "default_images", + " default_images", + ), + ] { + let toml = crate_test_settings_str() + section; + let raw: Settings = + toml::from_str(&toml).expect("should deserialize unnormalized settings"); + let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); + let toml_error = toml::from_str::(&toml) + .expect_err("should reject colliding app-config keys"); + let json_error = serde_json::from_value::(value) + .expect_err("should reject colliding app-config keys"); + + for message in [toml_error.to_string(), json_error.to_string()] { + for expected in [ + "image_optimizer.profile_sets", + "default_images", + "collide after trimming", + &format!("{first:?}"), + &format!("{second:?}"), + ] { + assert!( + message.contains(expected), + "should preserve collision context with {expected:?}: {message}" + ); + } + } + } + } + + #[test] + fn app_config_image_optimizer_normalizes_non_colliding_keys() { + let toml = crate_test_settings_str() + + r#" + [image_optimizer.profile_sets." default_images "] + default_profile = " medium " + [image_optimizer.profile_sets." default_images ".profiles] + " medium " = " width=100 " + "#; + let app_config: TrustedServerAppConfig = + toml::from_str(&toml).expect("should deserialize non-colliding app config"); + let profile_set = &app_config.settings().image_optimizer.profile_sets["default_images"]; + assert_eq!( + profile_set.default_profile, "medium", + "should trim the default profile reference" + ); + assert_eq!( + profile_set.profiles["medium"], "width=100", + "should trim profile keys and values" + ); + } + #[test] fn push_validation_accepts_secret_key_names() { let mut settings = valid_settings(); @@ -919,7 +994,9 @@ formats = [{ width = 300, height = 250 }] })); settings.proxy.asset_routes.push(route); - settings.normalize_deserialized(); + settings + .normalize_deserialized() + .expect("should normalize settings without key collisions"); let serialized = serde_json::to_string(&settings).expect("should serialize settings"); for legacy_store in [ diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0c20b2b1f..3efc15770 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1023,7 +1023,28 @@ pub struct ImageOptimizerSettings { } impl ImageOptimizerSettings { - fn normalize(&mut self) { + /// Trim image configuration keys and values, rejecting keys that collide after trimming. + /// + /// # Errors + /// + /// Returns a configuration error if retained profile-set or profile keys + /// collide after trimming. + fn normalize(&mut self) -> Result<(), Report> { + validate_trimmed_image_optimizer_keys( + self.profile_sets.keys(), + "image_optimizer.profile_sets", + )?; + let mut profile_sets: Vec<_> = self.profile_sets.iter().collect(); + profile_sets.sort_unstable_by_key(|(name, _)| *name); + for (name, profile_set) in profile_sets { + if !name.trim().is_empty() { + validate_trimmed_image_optimizer_keys( + profile_set.profiles.keys(), + &format!("image_optimizer.profile_sets.{name:?}.profiles"), + )?; + } + } + self.profile_sets = self .profile_sets .drain() @@ -1033,6 +1054,7 @@ impl ImageOptimizerSettings { }) .filter(|(key, _)| !key.is_empty()) .collect(); + Ok(()) } /// Eagerly validate configured image profile sets. @@ -1044,6 +1066,36 @@ impl ImageOptimizerSettings { } } +/// Check retained image configuration keys before normalization discards originals. +/// +/// # Errors +/// +/// Returns a configuration error naming both original keys when they trim to +/// the same non-empty name. +fn validate_trimmed_image_optimizer_keys<'a>( + keys: impl Iterator, + path: &str, +) -> Result<(), Report> { + let mut names: Vec<_> = keys + .map(|key| (key.trim(), key.as_str())) + .filter(|(trimmed, _)| !trimmed.is_empty()) + .collect(); + names.sort_unstable(); + + for pair in names.windows(2) { + let (trimmed, first) = pair[0]; + let (next_trimmed, second) = pair[1]; + if trimmed == next_trimmed { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "{path} keys {first:?} and {second:?} collide after trimming to {trimmed:?}" + ), + })); + } + } + Ok(()) +} + /// Named set of profile-table Image Optimizer mappings. /// /// Each profile value is a URL-encoded parameter string using the strict @@ -3021,22 +3073,28 @@ impl Settings { Self::finalize_deserialized(settings, "Build-time configuration") } - pub(crate) fn normalize_deserialized(&mut self) { + /// Normalize settings after deserialization. + /// + /// # Errors + /// + /// Returns a configuration error if image optimizer keys collide after trimming. + pub(crate) fn normalize_deserialized(&mut self) -> Result<(), Report> { self.cache.normalize(); self.proxy.normalize(); - self.image_optimizer.normalize(); + self.image_optimizer.normalize()?; self.debug.auction_html_comment_options.normalize(); self.tinybird.normalize(); self.integrations .remove_legacy_static_secret_store_selectors(); self.consent.validate(); + Ok(()) } pub(crate) fn finalize_deserialized( mut settings: Self, validation_label: &str, ) -> Result> { - settings.normalize_deserialized(); + settings.normalize_deserialized()?; settings.prepare_runtime()?; settings.validate().map_err(|err| { @@ -7042,6 +7100,251 @@ source_domain = "partner.example.com" ); } + fn assert_image_optimizer_key_collision(toml: &str, expected_fragments: &[&str]) { + let raw: Settings = toml::from_str(toml).expect("should deserialize unnormalized settings"); + let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); + let mut messages = Vec::new(); + + for result in [Settings::from_toml(toml), Settings::from_json_value(value)] { + let error = result.expect_err("should reject colliding image optimizer keys"); + assert!( + matches!( + error.current_context(), + TrustedServerError::Configuration { .. } + ), + "should return a configuration error" + ); + let message = error.to_string(); + for expected in expected_fragments { + assert!( + message.contains(expected), + "should identify the collision with {expected:?}: {message}" + ); + } + messages.push(message); + } + assert_eq!(messages[0], messages[1], "should report a stable collision"); + } + + #[test] + fn image_optimizer_rejects_profile_keys_that_collide_after_trimming() { + for (first, second, second_params) in [ + ("medium", " medium", "width=200"), + ("medium ", "\tmedium", "width=200"), + (" medium ", "medium", "width=100"), + ] { + let toml = format!( + "{}\n[image_optimizer.profile_sets.default_images]\n\ + default_profile = \"medium\"\n\ + [image_optimizer.profile_sets.default_images.profiles]\n\ + {first:?} = \"width=100\"\n{second:?} = {second_params:?}\n", + crate_test_settings_str() + ); + assert_image_optimizer_key_collision( + &toml, + &[ + "image_optimizer.profile_sets", + "default_images", + "profiles", + "collide after trimming", + &format!("{first:?}"), + &format!("{second:?}"), + "\"medium\"", + ], + ); + } + } + + #[test] + fn image_optimizer_rejects_profile_set_keys_that_collide_after_trimming() { + for (first, second, second_params) in [ + ("default_images", " default_images", "width=200"), + ("default_images ", "\tdefault_images", "width=200"), + (" default_images ", "default_images", "width=100"), + ] { + let toml = format!( + "{}\n[image_optimizer.profile_sets.{first:?}.profiles]\n\ + default = \"width=100\"\n\ + [image_optimizer.profile_sets.{second:?}.profiles]\n\ + default = {second_params:?}\n", + crate_test_settings_str() + ); + assert_image_optimizer_key_collision( + &toml, + &[ + "image_optimizer.profile_sets", + "collide after trimming", + &format!("{first:?}"), + &format!("{second:?}"), + "\"default_images\"", + ], + ); + } + } + + #[test] + fn image_optimizer_reports_first_profile_key_collision_in_stable_set_order() { + let toml = crate_test_settings_str() + + r#" + [image_optimizer.profile_sets." alpha_images ".profiles] + medium = "width=100" + " medium" = "width=200" + + [image_optimizer.profile_sets.zeta_images.profiles] + medium = "width=300" + " medium" = "width=400" + "#; + + for _ in 0..32 { + assert_image_optimizer_key_collision( + &toml, + &[ + r#"image_optimizer.profile_sets." alpha_images ".profiles keys " medium" and "medium" collide after trimming to "medium""#, + ], + ); + } + } + + #[test] + fn image_optimizer_normalization_preserves_non_colliding_configuration() { + let toml = crate_test_settings_str() + + r#" + [image_optimizer.profile_sets." default_images "] + base_params = " quality=70 " + default_profile = " medium " + profile_param = " profile " + aspect_ratio_param = " ratio " + debug_param = " debug " + + [image_optimizer.profile_sets." default_images ".profiles] + " medium " = " width=100 " + large = "width=200" + "" = "ignored" + " " = "also ignored" + + [image_optimizer.profile_sets." default_images ".aspect_ratios] + allowed = [" 1-1 ", ""] + profiles = [" medium ", ""] + + [image_optimizer.profile_sets." default_images ".crop_offsets] + x_param = " x " + y_param = " y " + buckets = [90, 10, 10] + + [image_optimizer.profile_sets.other_images.profiles] + medium = "width=300" + default = "width=400" + + [image_optimizer.profile_sets."".profiles] + default = "ignored" + " default " = "also ignored" + + [image_optimizer.profile_sets." "] + + [[proxy.asset_routes]] + prefix = "/images/" + origin_url = "https://assets.example.com" + + [proxy.asset_routes.image_optimizer] + region = " us_east " + profile_set = " default_images " + "#; + let raw: Settings = + toml::from_str(&toml).expect("should deserialize unnormalized settings"); + let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); + + for result in [Settings::from_toml(&toml), Settings::from_json_value(value)] { + let settings = result.expect("should accept non-colliding keys"); + assert_eq!( + settings.image_optimizer.profile_sets.len(), + 2, + "should discard empty profile-set names" + ); + let profile_set = &settings.image_optimizer.profile_sets["default_images"]; + assert_eq!( + profile_set.base_params, "quality=70", + "should trim base parameters" + ); + assert_eq!( + profile_set.default_profile, "medium", + "should trim the default profile reference" + ); + assert_eq!( + profile_set.profile_param, "profile", + "should trim the profile query parameter name" + ); + assert_eq!( + profile_set.aspect_ratio_param, "ratio", + "should trim the aspect ratio query parameter name" + ); + assert_eq!( + profile_set.debug_param, "debug", + "should trim the debug query parameter name" + ); + assert_eq!( + profile_set.profiles.len(), + 2, + "should discard empty profile names" + ); + assert_eq!( + profile_set.profiles["medium"], "width=100", + "should trim profile keys and values" + ); + assert_eq!( + profile_set.profiles["large"], "width=200", + "should preserve unpadded profile keys and values" + ); + assert_eq!( + settings.image_optimizer.profile_sets["other_images"].profiles["medium"], + "width=300", + "should allow the same profile name in distinct sets" + ); + let ratios = profile_set + .aspect_ratios + .as_ref() + .expect("should retain ratios"); + assert_eq!( + ratios.allowed, + ["1-1"], + "should trim allowed aspect ratios and discard empty entries" + ); + assert_eq!( + ratios.profiles, + ["medium"], + "should trim aspect ratio profile references and discard empty entries" + ); + let offsets = profile_set + .crop_offsets + .as_ref() + .expect("should retain offsets"); + assert_eq!( + offsets.x_param, "x", + "should trim the horizontal crop offset parameter name" + ); + assert_eq!( + offsets.y_param, "y", + "should trim the vertical crop offset parameter name" + ); + assert_eq!( + offsets.buckets, + [10, 90], + "should sort and deduplicate crop offset buckets" + ); + let route = settings.proxy.asset_routes[0] + .image_optimizer + .as_ref() + .expect("should retain the route's image optimizer"); + assert_eq!( + route.region, "us_east", + "should trim the image optimizer region" + ); + assert_eq!( + route.profile_set, "default_images", + "should trim the route's profile-set reference" + ); + } + } + #[test] fn image_optimizer_validation_rejects_unknown_aspect_ratio_profile() { let toml_str = crate_test_settings_str() diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a9dcaad1f..81d936ec2 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1252,6 +1252,8 @@ Profile sets convert small request query controls into a closed set of Image Opt Profile values live under `[image_optimizer.profile_sets..profiles]` and use query-string syntax. +Profile and profile-set names are trimmed. Names within the same map that become identical after trimming are rejected, even when their values match. Empty names continue to be discarded. + ```toml [image_optimizer.profile_sets.default_images] base_params = "quality=70&resize-filter=bicubic"