From 063f00b7427aabf08df8240760b29410270e314c Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 5 Nov 2026 19:03:58 +0530 Subject: [PATCH 1/5] Run js_asset_proxy attribute rewriter before plan-registered integrations --- crates/trusted-server-core/src/auction/mod.rs | 63 ++++++++ crates/trusted-server-core/src/config.rs | 102 ++++++++++++- .../src/integrations/js_asset_proxy.rs | 54 +++++++ .../src/integrations/mod.rs | 5 +- .../src/integrations/prebid.rs | 135 ++++++++++-------- .../src/integrations/registry.rs | 19 +++ 6 files changed, 315 insertions(+), 63 deletions(-) diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 4323039284..853bb9e894 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -230,4 +230,67 @@ mod plan_sharing_tests { assert!(registry.has_route(&http::Method::GET, "/integrations/aps/renderer")); } + + #[test] + fn js_asset_proxy_rewriter_runs_before_plan_registered_rewriters() { + // `create_test_settings` enables Prebid; add an APS profile, GPT and + // `js_asset_proxy` so plan registrations precede the builder table. + let mut settings = create_test_settings(); + settings.auction.providers = std::collections::BTreeMap::from([( + "aps-main".parse().expect("should parse APS provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: None, + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config: serde_json::json!({"account_id":"example-account"}), + }, + )]); + settings + .integrations + .insert_config("gpt", &serde_json::json!({ "enabled": true })) + .expect("should insert GPT config"); + settings + .integrations + .insert_config( + "js_asset_proxy", + &serde_json::json!({ + "enabled": true, + "assets": [{ + "path": "/assets/vendor.js", + "origin_url": "https://cdn.example.com/vendor.js" + }] + }), + ) + .expect("should insert JS asset proxy config"); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile APS plan")); + + let registry = + IntegrationRegistry::with_plan(&settings, plan).expect("should build registry"); + + assert!( + registry.integration_enabled("aps") && registry.integration_enabled("prebid"), + "should register APS and Prebid from the plan" + ); + let ids = registry.attribute_rewriter_ids(); + assert_eq!( + ids.first(), + Some(&"js_asset_proxy"), + "js_asset_proxy should be the first attribute rewriter: {ids:?}" + ); + let prebid_index = ids + .iter() + .position(|id| *id == "prebid") + .expect("should register Prebid attribute rewriter"); + let gpt_index = ids + .iter() + .position(|id| *id == "gpt") + .expect("should register GPT attribute rewriter"); + assert!( + prebid_index < gpt_index, + "other rewriters should keep registration order: {ids:?}" + ); + } } diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 059d500092..d084de4212 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -23,7 +23,7 @@ use crate::integrations::{ google_tag_manager::GoogleTagManagerConfig, gpt::GptConfig, gpt_diagnostics::{GPT_DIAGNOSTICS_INTEGRATION_ID, GptDiagnosticsConfig}, - js_asset_proxy::{JS_ASSET_PROXY_INTEGRATION_ID, JsAssetProxyConfig}, + js_asset_proxy::{JS_ASSET_PROXY_INTEGRATION_ID, JsAssetProxyConfig, JsAssetProxyMode}, lockr::LockrConfig, nextjs::NextJsIntegrationConfig, osano::OsanoConfig, @@ -330,7 +330,39 @@ fn validate_js_asset_proxy_config(settings: &Settings) -> Result<(), Report Result<(), Report> { + if !config.enabled { + return Ok(()); + } + let Some(prebid_config) = + settings.integration_config::("prebid")? + else { + return Ok(()); + }; + let Some(asset) = config.assets.iter().find(|asset| { + asset.proxy == JsAssetProxyMode::Enabled + && prebid_config.matches_script_url(&asset.origin_url) + }) else { + return Ok(()); + }; + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "integration startup failed for `{JS_ASSET_PROXY_INTEGRATION_ID}`: asset `{}` with origin_url `{}` has proxy = \"enabled\" in [[integrations.js_asset_proxy.assets]], but [integrations.prebid] script_patterns intercept that URL to prevent duplicate Prebid instances; set proxy = \"disabled\" to keep Prebid's interception, set proxy = \"blocked\" to remove the script, or remove the matching pattern from [integrations.prebid] script_patterns (not recommended; may duplicate the managed bundle)", + asset.path, asset.origin_url + ), + })) } fn validate_enabled_integrations( @@ -1589,6 +1621,72 @@ password = "production-admin-password-32-bytes" ); } + fn settings_with_prebid_script_asset(mode: &str) -> Settings { + // `valid_settings` enables `[integrations.prebid]` with the default + // `script_patterns`, which match `/prebid.min.js`. + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + JS_ASSET_PROXY_INTEGRATION_ID, + &serde_json::json!({ + "enabled": true, + "assets": [{ + "path": "/assets/vendor-pb.js", + "origin_url": "https://cdn.example.com/prebid.min.js", + "proxy": mode + }] + }), + ) + .expect("should insert JS asset proxy config"); + settings + } + + #[test] + fn validation_rejects_enabled_js_asset_intercepted_by_prebid() { + let settings = settings_with_prebid_script_asset("enabled"); + + for result in [ + validate_settings_for_deploy(&settings), + validate_settings_for_runtime(&settings), + ] { + let message = result + .expect_err("should reject enabled asset intercepted by Prebid") + .to_string(); + assert!( + message.contains("[[integrations.js_asset_proxy.assets]]") + && message.contains("[integrations.prebid]"), + "error should name both tables: {message}" + ); + assert!( + message.contains("/assets/vendor-pb.js"), + "error should name the conflicting asset: {message}" + ); + } + } + + #[test] + fn validation_accepts_disabled_and_blocked_js_assets_intercepted_by_prebid() { + for mode in ["disabled", "blocked"] { + let settings = settings_with_prebid_script_asset(mode); + + validate_settings_for_deploy(&settings) + .expect("should accept non-enabled asset intercepted by Prebid"); + } + } + + #[test] + fn validation_accepts_enabled_prebid_script_asset_when_prebid_disabled() { + let mut settings = settings_with_prebid_script_asset("enabled"); + settings + .integrations + .insert_config("prebid", &serde_json::json!({ "enabled": false })) + .expect("should insert disabled prebid config"); + + validate_settings_for_deploy(&settings) + .expect("should accept enabled asset when Prebid interception is off"); + } + #[test] fn deploy_validation_requires_external_bundle_url_for_enabled_prebid() { let mut settings = valid_settings(); diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index bb1a875e8f..5c8e341b36 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -656,6 +656,60 @@ mod tests { String::from_utf8(output).expect("should produce UTF-8 HTML") } + fn process_prebid_script_with_asset_mode(mode: &str) -> String { + // `create_test_settings` enables `[integrations.prebid]` with the default + // `script_patterns`, so its attribute rewriter matches `/prebid.min.js`. + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + JS_ASSET_PROXY_INTEGRATION_ID, + &json!({ + "enabled": true, + "assets": [{ + "path": "/assets/vendor-pb.js", + "origin_url": "https://cdn.example.com/prebid.min.js", + "proxy": mode + }] + }), + ) + .expect("should insert JS asset proxy config"); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let html = r#""#; + + process_html_with_registry(html, registry) + } + + #[test] + fn js_asset_proxy_rewriter_takes_precedence_over_prebid_script_removal() { + let processed = process_prebid_script_with_asset_mode("enabled"); + + assert!( + processed.contains(r#""#), + "JS asset proxy should rewrite before Prebid removes the script: {processed}" + ); + } + + #[test] + fn blocked_js_asset_proxy_entry_removes_prebid_script() { + let processed = process_prebid_script_with_asset_mode("blocked"); + + assert!( + !processed.contains("prebid.min.js") && !processed.contains(" &'static [IntegrationBuilder] { &[ - // This must remain first: attribute rewriters chain replacements and short-circuit removals. + // Attribute rewriters chain replacements and short-circuit removals, so + // `js_asset_proxy` must run first. `IntegrationRegistry::with_plan` enforces + // this by sorting its attribute rewriter ahead of every other one; list + // position here does not decide precedence. IntegrationBuilder { id: js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID, build: js_asset_proxy::register, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c13260704f..4149f0f525 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -586,6 +586,79 @@ impl Default for PrebidIntegrationConfig { } } +impl PrebidIntegrationConfig { + /// Reports whether Prebid's script interception removes a `"#; + let html = format!(r#""#); - process_html_with_registry(html, registry) + process_html_with_registry(&html, registry) + } + + fn process_prebid_script_with_asset_mode(mode: &str) -> String { + process_script_with_asset( + "/assets/vendor-pb.js", + "https://cdn.example.com/prebid.min.js", + mode, + ) } #[test] fn js_asset_proxy_rewriter_takes_precedence_over_prebid_script_removal() { + // Settings validation rejects this asset because it would load a second + // Prebid.js; the registry does not validate, so this pins rewriter order. let processed = process_prebid_script_with_asset_mode("enabled"); assert!( @@ -690,6 +700,22 @@ mod tests { ); } + #[test] + fn enabled_js_asset_proxy_replacement_is_not_rewritten_by_prebid() { + // The proxied `path` matches Prebid `script_patterns`; the replacement + // must be terminal so Prebid does not remove the rewritten script. + let processed = process_script_with_asset( + "/assets/prebid.min.js", + "https://cdn.example.com/vendor.js", + "enabled", + ); + + assert!( + processed.contains(r#""#), + "JS asset proxy replacement should be terminal: {processed}" + ); + } + #[test] fn blocked_js_asset_proxy_entry_removes_prebid_script() { let processed = process_prebid_script_with_asset_mode("blocked"); diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 197ef9880f..4588c8f223 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -290,10 +290,10 @@ pub(crate) struct IntegrationBuilder { pub(crate) fn builders() -> &'static [IntegrationBuilder] { &[ - // Attribute rewriters chain replacements and short-circuit removals, so - // `js_asset_proxy` must run first. `IntegrationRegistry::with_plan` enforces - // this by sorting its attribute rewriter ahead of every other one; list - // position here does not decide precedence. + // `js_asset_proxy` must decide first, and its replacements are terminal. + // `IntegrationRegistry::with_plan` enforces this by sorting its attribute + // rewriter ahead of every other one; list position here does not decide + // precedence. IntegrationBuilder { id: js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID, build: js_asset_proxy::register, diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 0b78ff7830..3a282afa33 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -922,9 +922,10 @@ impl IntegrationRegistry { } // `js_asset_proxy` is the operator's per-URL override, so it runs before - // every native attribute rewriter regardless of registration order: - // `rewrite_attribute` chains replacements and short-circuits removals. - // The sort is stable, so the other rewriters keep registration order. + // every native attribute rewriter regardless of registration order, and + // `rewrite_attribute` treats its replacement as terminal. Other + // replacements chain and removals short-circuit. The sort is stable, so + // the other rewriters keep registration order. inner.html_rewriters.sort_by_key(|rewriter| { rewriter.integration_id() != crate::integrations::js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID @@ -1066,6 +1067,9 @@ impl IntegrationRegistry { } /// Give integrations a chance to rewrite HTML attributes. + /// + /// Replacements chain through later rewriters, except a `js_asset_proxy` + /// replacement, which is returned as is. A removal ends the chain. #[must_use] pub fn rewrite_attribute( &self, @@ -1082,6 +1086,13 @@ impl IntegrationRegistry { match rewriter.rewrite(attr_name, ¤t, ctx) { AttributeRewriteAction::Keep => {} AttributeRewriteAction::Replace(next_value) => { + // A `js_asset_proxy` replacement is the operator's explicit + // per-URL decision, so no later rewriter sees or changes it. + if rewriter.integration_id() + == crate::integrations::js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID + { + return AttributeRewriteOutcome::Replaced(next_value); + } current = next_value; changed = true; } From 49992fad65deb11c005a37929bb1664342c968f8 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Tue, 10 Nov 2026 18:56:18 +0530 Subject: [PATCH 3/5] Report every js_asset_proxy asset that Prebid intercepts Collect all conflicting enabled assets into one validation error, pin the disabled-integration guard with a test, widen the matches_script_url doc to src and href, and share the APS provider fixture in plan_sharing_tests. Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/auction/mod.rs | 41 ++++----- crates/trusted-server-core/src/config.rs | 83 +++++++++++++++++-- .../src/integrations/prebid.rs | 2 +- 3 files changed, 92 insertions(+), 34 deletions(-) diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 853bb9e894..3c0927dc89 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -130,6 +130,21 @@ mod plan_sharing_tests { use crate::integrations::IntegrationRegistry; use crate::test_support::tests::create_test_settings; + fn aps_provider_map() -> std::collections::BTreeMap { + std::collections::BTreeMap::from([( + "aps-main".parse().expect("should parse APS provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: None, + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config: serde_json::json!({"account_id":"example-account"}), + }, + )]) + } + #[test] fn orchestrator_and_registry_share_the_compiled_plan_allocation() { let settings = create_test_settings(); @@ -212,18 +227,7 @@ mod plan_sharing_tests { #[test] fn aps_profile_registers_renderer_without_browser_aps_config() { let mut settings = create_test_settings(); - settings.auction.providers = std::collections::BTreeMap::from([( - "aps-main".parse().expect("should parse APS provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "aps".to_string(), - endpoint: "https://aps.example/e/pb/bid".to_string(), - timeout_ms: None, - routing: RoutingMode::AllEligible, - notifications: NotificationConfig::default(), - profile_config: serde_json::json!({"account_id":"example-account"}), - }, - )]); + settings.auction.providers = aps_provider_map(); let plan = Arc::new(compile_auction_plan(&settings).expect("should compile APS plan")); let registry = IntegrationRegistry::with_plan(&settings, plan) .expect("should build APS renderer registry"); @@ -236,18 +240,7 @@ mod plan_sharing_tests { // `create_test_settings` enables Prebid; add an APS profile, GPT and // `js_asset_proxy` so plan registrations precede the builder table. let mut settings = create_test_settings(); - settings.auction.providers = std::collections::BTreeMap::from([( - "aps-main".parse().expect("should parse APS provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "aps".to_string(), - endpoint: "https://aps.example/e/pb/bid".to_string(), - timeout_ms: None, - routing: RoutingMode::AllEligible, - notifications: NotificationConfig::default(), - profile_config: serde_json::json!({"account_id":"example-account"}), - }, - )]); + settings.auction.providers = aps_provider_map(); settings .integrations .insert_config("gpt", &serde_json::json!({ "enabled": true })) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index d084de4212..d7373ffc53 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -337,8 +337,9 @@ fn validate_js_asset_proxy_config(settings: &Settings) -> Result<(), Report = config + .assets + .iter() + .filter(|asset| { + asset.proxy == JsAssetProxyMode::Enabled + && prebid_config.matches_script_url(&asset.origin_url) + }) + .map(|asset| format!("`{}` (origin_url `{}`)", asset.path, asset.origin_url)) + .collect(); + if conflicts.is_empty() { return Ok(()); - }; + } Err(Report::new(TrustedServerError::Configuration { message: format!( - "integration startup failed for `{JS_ASSET_PROXY_INTEGRATION_ID}`: asset `{}` with origin_url `{}` has proxy = \"enabled\" in [[integrations.js_asset_proxy.assets]], but [integrations.prebid] script_patterns intercept that URL to prevent duplicate Prebid instances; set proxy = \"disabled\" to keep Prebid's interception, set proxy = \"blocked\" to remove the script, or remove the matching pattern from [integrations.prebid] script_patterns (not recommended; may duplicate the managed bundle)", - asset.path, asset.origin_url + "integration startup failed for `{JS_ASSET_PROXY_INTEGRATION_ID}`: assets {} have proxy = \"enabled\" in [[integrations.js_asset_proxy.assets]], but [integrations.prebid] script_patterns intercept those URLs to prevent duplicate Prebid instances; set proxy = \"disabled\" to keep Prebid's interception, set proxy = \"blocked\" to remove the script, or remove the matching pattern from [integrations.prebid] script_patterns (not recommended; may duplicate the managed bundle)", + conflicts.join(", ") ), })) } @@ -1687,6 +1694,64 @@ password = "production-admin-password-32-bytes" .expect("should accept enabled asset when Prebid interception is off"); } + #[test] + fn validation_accepts_enabled_prebid_script_asset_when_js_asset_proxy_disabled() { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + JS_ASSET_PROXY_INTEGRATION_ID, + &serde_json::json!({ + "enabled": false, + "assets": [{ + "path": "/assets/vendor-pb.js", + "origin_url": "https://cdn.example.com/prebid.min.js", + "proxy": "enabled" + }] + }), + ) + .expect("should insert disabled JS asset proxy config"); + + validate_settings_for_deploy(&settings) + .expect("should accept a staged enabled asset while JS asset proxy is disabled"); + } + + #[test] + fn validation_reports_every_enabled_js_asset_intercepted_by_prebid() { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + JS_ASSET_PROXY_INTEGRATION_ID, + &serde_json::json!({ + "enabled": true, + "assets": [ + { + "path": "/assets/vendor-pb.js", + "origin_url": "https://cdn.example.com/prebid.js", + "proxy": "enabled" + }, + { + "path": "/assets/vendor-pb-min.js", + "origin_url": "https://cdn.example.com/prebid.min.js", + "proxy": "enabled" + } + ] + }), + ) + .expect("should insert JS asset proxy config"); + + let message = validate_settings_for_deploy(&settings) + .expect_err("should reject enabled assets intercepted by Prebid") + .to_string(); + + assert!( + message.contains("/assets/vendor-pb.js") + && message.contains("/assets/vendor-pb-min.js"), + "error should name every conflicting asset: {message}" + ); + } + #[test] fn deploy_validation_requires_external_bundle_url_for_enabled_prebid() { let mut settings = valid_settings(); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 4149f0f525..c3fb73aa2d 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -587,7 +587,7 @@ impl Default for PrebidIntegrationConfig { } impl PrebidIntegrationConfig { - /// Reports whether Prebid's script interception removes a `