diff --git a/CHANGELOG.md b/CHANGELOG.md index 2512b6299..a7470a1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** With `[integrations.prebid]` enabled, JS asset entries with + `proxy = "enabled"` whose `origin_url` matches Prebid `script_patterns` + now fail deploy validation and runtime startup. Before upgrading the + server, use the new `ts` CLI to run `ts config validate`, change overlapping + assets to `proxy = "disabled"` (preserves Prebid interception) or + `proxy = "blocked"`, and publish the corrected config with `ts config push`. + JS Asset Proxy now rewrites script tags before every other integration, and + its `enabled` replacement is final: no later integration rewrites or removes + the proxied tag. - The attested Fastly wasm reports `x-ts-version` as the full 40-character source commit; deploy-pipeline builds keep the tag, branch or short-commit value described below. - **Breaking:** `ts --version` now prints `ts package= source= target= variant=default` instead of `ts `. `source` is the full commit in attested release binaries, and otherwise the development ref, short commit or `unknown`. Update scripts that parse the old output. - **Breaking:** Creative rewrite proxy, click, CSS, `srcset`, sign-response, rebuild-response, and injected creative TSJS URLs are now absolute on `publisher.public_origin`. Review the [default authority, cookie scope, and coordinated upgrade/rollback warning for `public_origin`](docs/guide/configuration.md#publisher-configuration) before deployment. Existing root-relative signed proxy and click inputs remain accepted. TSJS bundle selection and hash calculation are unchanged. diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 432303928..3c0927dc8 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,22 +227,63 @@ 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"); 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 = aps_provider_map(); + 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 141a06867..cab948b9b 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -24,7 +24,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, @@ -385,7 +385,46 @@ 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 conflicts: Vec = 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}`: 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(", ") + ), + })) } fn validate_enabled_integrations( @@ -1881,6 +1920,130 @@ 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 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/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index c8a61b938..7e8c2e90e 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -657,6 +657,144 @@ mod tests { String::from_utf8(output).expect("should produce UTF-8 HTML") } + fn process_script_with_asset(path: &str, origin_url: &str, 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": path, + "origin_url": origin_url, + "proxy": mode + }] + }), + ) + .expect("should insert JS asset proxy config"); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let html = format!(r#""#); + + 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!( + processed.contains(r#""#), + "JS asset proxy should rewrite before Prebid removes the script: {processed}" + ); + } + + #[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}" + ); + } + + /// Appends `?chained` so a test can see whether a replacement reached it. + struct AppendChainedRewriter; + + impl IntegrationAttributeRewriter for AppendChainedRewriter { + fn integration_id(&self) -> &'static str { + "append_chained" + } + + fn handles_attribute(&self, attribute: &str) -> bool { + attribute == "src" + } + + fn rewrite( + &self, + _attr_name: &str, + attr_value: &str, + _ctx: &IntegrationAttributeContext<'_>, + ) -> AttributeRewriteAction { + AttributeRewriteAction::replace(format!("{attr_value}?chained")) + } + } + + #[test] + fn js_asset_proxy_replacement_is_terminal_while_other_replacements_chain() { + let proxy: Arc = + JsAssetProxyIntegration::new(config_with_assets(vec![asset( + "/assets/vendor.js", + "https://cdn.example.com/vendor.js", + JsAssetProxyMode::Enabled, + )])); + let chained: Arc = Arc::new(AppendChainedRewriter); + let registry = IntegrationRegistry::from_rewriters( + vec![proxy, Arc::clone(&chained), chained], + Vec::new(), + ); + + assert_eq!( + registry.rewrite_attribute( + "src", + "https://cdn.example.com/vendor.js", + &rewrite_context() + ), + crate::integrations::AttributeRewriteOutcome::Replaced("/assets/vendor.js".to_string()), + "later rewriters should not see the JS asset proxy replacement" + ); + assert_eq!( + registry.rewrite_attribute( + "src", + "https://cdn.example.com/other.js", + &rewrite_context() + ), + crate::integrations::AttributeRewriteOutcome::Replaced( + "https://cdn.example.com/other.js?chained?chained".to_string() + ), + "other replacements should keep chaining" + ); + } + + #[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. + // `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/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c13260704..c3fb73aa2 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 interception removes an element with this `src` or `href` value. + /// + /// The query and fragment are ignored, and the URL path is matched against + /// [`Self::script_patterns`] case-insensitively. + pub(crate) fn matches_script_url(&self, attr_value: &str) -> bool { + let trimmed = attr_value.trim(); + let without_query = trimmed.split(['?', '#']).next().unwrap_or(trimmed); + + if self.matches_script_pattern(without_query) { + return true; + } + + if !without_query.starts_with('/') + && !without_query.starts_with("//") + && !without_query.contains("://") + { + let with_slash = format!("/{without_query}"); + if self.matches_script_pattern(&with_slash) { + return true; + } + } + + let parsed = if without_query.starts_with("//") { + ParsedUrl::parse(&format!("https:{without_query}")) + } else { + ParsedUrl::parse(without_query) + }; + + parsed + .ok() + .is_some_and(|url| self.matches_script_pattern(url.path())) + } + + /// Reports whether a request path matches one of [`Self::script_patterns`]. + fn matches_script_pattern(&self, path: &str) -> bool { + // Normalize path to lowercase for case-insensitive matching + let path_lower = path.to_ascii_lowercase(); + + // Check if path matches any configured pattern + for pattern in &self.script_patterns { + let pattern_lower = pattern.to_ascii_lowercase(); + + // Check for wildcard patterns: /* or {*name} + if pattern_lower.ends_with("/*") || pattern_lower.contains("{*") { + // Extract prefix before the wildcard + let prefix = if pattern_lower.ends_with("/*") { + &pattern_lower[..pattern_lower.len() - 1] // Remove trailing * + } else { + // Find {* and extract prefix before it + pattern_lower.split("{*").next().unwrap_or("") + }; + + if path_lower.starts_with(prefix) { + // Check if it ends with a known Prebid script suffix + if PREBID_SCRIPT_SUFFIXES + .iter() + .any(|suffix| path_lower.ends_with(suffix)) + { + return true; + } + } + } else { + // Exact match or suffix match + if path_lower.ends_with(&pattern_lower) { + return true; + } + } + } + false + } +} + impl IntegrationConfig for PrebidIntegrationConfig { fn is_enabled(&self) -> bool { self.enabled @@ -1024,69 +1097,11 @@ impl PrebidIntegration { } fn matches_script_url(&self, attr_value: &str) -> bool { - let trimmed = attr_value.trim(); - let without_query = trimmed.split(['?', '#']).next().unwrap_or(trimmed); - - if self.matches_script_pattern(without_query) { - return true; - } - - if !without_query.starts_with('/') - && !without_query.starts_with("//") - && !without_query.contains("://") - { - let with_slash = format!("/{without_query}"); - if self.matches_script_pattern(&with_slash) { - return true; - } - } - - let parsed = if without_query.starts_with("//") { - ParsedUrl::parse(&format!("https:{without_query}")) - } else { - ParsedUrl::parse(without_query) - }; - - parsed - .ok() - .is_some_and(|url| self.matches_script_pattern(url.path())) + self.config.matches_script_url(attr_value) } fn matches_script_pattern(&self, path: &str) -> bool { - // Normalize path to lowercase for case-insensitive matching - let path_lower = path.to_ascii_lowercase(); - - // Check if path matches any configured pattern - for pattern in &self.config.script_patterns { - let pattern_lower = pattern.to_ascii_lowercase(); - - // Check for wildcard patterns: /* or {*name} - if pattern_lower.ends_with("/*") || pattern_lower.contains("{*") { - // Extract prefix before the wildcard - let prefix = if pattern_lower.ends_with("/*") { - &pattern_lower[..pattern_lower.len() - 1] // Remove trailing * - } else { - // Find {* and extract prefix before it - pattern_lower.split("{*").next().unwrap_or("") - }; - - if path_lower.starts_with(prefix) { - // Check if it ends with a known Prebid script suffix - if PREBID_SCRIPT_SUFFIXES - .iter() - .any(|suffix| path_lower.ends_with(suffix)) - { - return true; - } - } - } else { - // Exact match or suffix match - if path_lower.ends_with(&pattern_lower) { - return true; - } - } - } - false + self.config.matches_script_pattern(path) } fn handle_script_handler( diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 15a6486fd..3a282afa3 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -921,6 +921,16 @@ impl IntegrationRegistry { } } + // `js_asset_proxy` is the operator's per-URL override, so it runs before + // 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 + }); + let registry = Self { inner: Arc::new(inner), plan: Some(plan), @@ -1057,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, @@ -1073,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; } @@ -1246,6 +1266,16 @@ impl IntegrationRegistry { map.into_values().collect() } + /// Return attribute rewriter integration IDs in execution order. + #[cfg(test)] + pub(crate) fn attribute_rewriter_ids(&self) -> Vec<&'static str> { + self.inner + .html_rewriters + .iter() + .map(|rewriter| rewriter.integration_id()) + .collect() + } + /// Return whether an integration is enabled in this registry. #[must_use] pub fn integration_enabled(&self, integration_id: &str) -> bool { diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 099c04634..418911429 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -351,6 +351,10 @@ register routes or rewrite HTML until you set runtime-injected scripts; JS Asset Proxy only rewrites matching script `src` URLs present in HTML processed by Trusted Server. +While `[integrations.prebid]` is enabled, an entry whose URL matches Prebid +`script_patterns` cannot be `"enabled"`; keep it `"disabled"` so Prebid +intercepts the script, or set it to `"blocked"`. + If a config already exists, avoid overwriting it: ```bash diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a51c0dcd1..4858ff9fb 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1963,6 +1963,17 @@ Each `[[integrations.js_asset_proxy.assets]]` entry: | `proxy` | String | `enabled` | `enabled`, `disabled`, or `blocked` (removes script tags) | | `cache_ttl_seconds` | Integer | None | Optional per-asset downstream cache TTL override | +JS Asset Proxy decides first for each script whose `src` matches an asset's +`origin_url`, before other integrations rewrite the tag: `enabled` serves it +from `path`, `blocked` removes the script, and `disabled` leaves it to other +integrations. + +While `[integrations.prebid]` is enabled, an `enabled` asset whose `origin_url` +matches Prebid `script_patterns` fails `ts config validate` and server startup, +because it would load a second Prebid.js next to the managed bundle. Use +`proxy = "disabled"` to let Prebid intercept the script, or `proxy = "blocked"` +to remove it. + ### lockr Integration **Section**: `[integrations.lockr]` diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index b7a05ac7b..e26d487dc 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -467,6 +467,10 @@ Optional Ed25519 request signing for bid request authentication and fraud preven The `script_patterns` configuration controls which publisher-provided Prebid scripts are intercepted and replaced with empty JavaScript. Trusted Server always injects its managed first-party `/integrations/prebid/bundle.js` script for the configured external bundle, so interception prevents duplicate Prebid instances. +A `[[integrations.js_asset_proxy.assets]]` entry for an intercepted URL must use +`proxy = "disabled"` (Prebid intercepts it) or `proxy = "blocked"`; `enabled` +fails validation. + **Pattern Matching**: - **Suffix matching**: `/prebid.min.js` matches any URL ending with that path