diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 3af42b8..79c359f 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -41,6 +41,7 @@ pub mod prebid_proxy; pub mod proxy; pub mod publisher; pub mod request_signing; +pub mod script_overrides; pub mod settings; pub mod settings_data; pub mod streaming_processor; diff --git a/crates/common/src/script_overrides.rs b/crates/common/src/script_overrides.rs new file mode 100644 index 0000000..6a426a4 --- /dev/null +++ b/crates/common/src/script_overrides.rs @@ -0,0 +1,53 @@ +use fastly::http::StatusCode; +use fastly::Response; + +use crate::settings::Settings; + +/// Handles requests for overridden scripts by returning an empty JavaScript response. +/// +/// This is useful for blocking or stubbing specific script files without breaking +/// the page. The response includes appropriate headers for caching and content type. +/// +/// # Returns +/// +/// Returns an HTTP 200 response with: +/// - Empty body with a comment explaining the override +/// - `Content-Type: application/javascript; charset=utf-8` +/// - Long cache headers for optimal CDN performance +#[allow(unused)] +pub fn handle_script_override(_settings: &Settings) -> Response { + let body = "// Script overridden by Trusted Server\n"; + + Response::from_status(StatusCode::OK) + .with_header("Content-Type", "application/javascript; charset=utf-8") + .with_header("Cache-Control", "public, max-age=31536000, immutable") + .with_body(body) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::tests::create_test_settings; + + #[test] + fn test_handle_script_override_returns_empty_js() { + let settings = create_test_settings(); + let response = handle_script_override(&settings); + + assert_eq!(response.get_status(), StatusCode::OK); + + let content_type = response + .get_header_str("Content-Type") + .expect("Content-Type header should be present"); + assert_eq!(content_type, "application/javascript; charset=utf-8"); + + let cache_control = response + .get_header_str("Cache-Control") + .expect("Cache-Control header should be present"); + assert!(cache_control.contains("max-age=31536000")); + assert!(cache_control.contains("immutable")); + + let body = response.into_body_str(); + assert!(body.contains("// Script overridden by Trusted Server")); + } +} diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index 60136cc..06afa1d 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -252,6 +252,14 @@ pub struct Rewrite { pub exclude_domains: Vec, } +#[derive(Debug, Default, Deserialize, Serialize, Validate)] +pub struct ScriptOverrides { + /// List of URL paths that should return empty JavaScript responses. + /// Supports exact path matching only. + #[serde(default)] + pub paths: Vec, +} + impl Rewrite { /// Checks if a URL should be excluded from rewriting based on domain matching #[allow(dead_code)] @@ -279,6 +287,14 @@ impl Rewrite { } } +impl ScriptOverrides { + /// Checks if a path should be overridden with an empty script + #[allow(dead_code)] + pub fn is_overridden(&self, path: &str) -> bool { + self.paths.iter().any(|p| p == path) + } +} + #[derive(Debug, Default, Deserialize, Serialize, Validate)] pub struct Handler { #[validate(length(min = 1), custom(function = validate_path))] @@ -336,6 +352,9 @@ pub struct Settings { #[serde(default)] #[validate(nested)] pub rewrite: Rewrite, + #[serde(default)] + #[validate(nested)] + pub script_overrides: ScriptOverrides, } #[allow(unused)] @@ -957,4 +976,83 @@ mod tests { assert!(!rewrite.is_excluded("not a url")); assert!(!rewrite.is_excluded("")); } + + #[test] + fn test_script_overrides_is_overridden() { + let overrides = ScriptOverrides { + paths: vec![ + "/.static/prebid/1.0.8/prebid.min.js".to_string(), + "/js/analytics.js".to_string(), + ], + }; + + // Should match exact paths + assert!(overrides.is_overridden("/.static/prebid/1.0.8/prebid.min.js")); + assert!(overrides.is_overridden("/js/analytics.js")); + + // Should not match similar but different paths + assert!(!overrides.is_overridden("/.static/prebid/1.0.9/prebid.min.js")); + assert!(!overrides.is_overridden("/js/analytics.min.js")); + assert!(!overrides.is_overridden("/other/path.js")); + assert!(!overrides.is_overridden("")); + } + + #[test] + fn test_script_overrides_empty() { + let overrides = ScriptOverrides { paths: vec![] }; + + assert!(!overrides.is_overridden("/.static/prebid/1.0.8/prebid.min.js")); + assert!(!overrides.is_overridden("/js/analytics.js")); + } + + #[test] + fn test_script_overrides_from_toml() { + let toml_str = r#" +[publisher] +domain = "test-publisher.com" +cookie_domain = ".test-publisher.com" +origin_url = "https://origin.test-publisher.com" +proxy_secret = "test-secret" + +[prebid] +server_url = "https://test-prebid.com/openrtb2/auction" + +[synthetic] +counter_store = "test-counter-store" +opid_store = "test-opid-store" +secret_key = "test-secret-key" +template = "{{client_ip}}:{{user_agent}}" + +[script_overrides] +paths = [ + "/.static/prebid/1.0.8/prebid.min.js", + "/js/old-analytics.js" +] +"#; + + let settings = Settings::from_toml(toml_str).expect("should parse TOML"); + + assert_eq!(settings.script_overrides.paths.len(), 2); + assert!(settings + .script_overrides + .is_overridden("/.static/prebid/1.0.8/prebid.min.js")); + assert!(settings + .script_overrides + .is_overridden("/js/old-analytics.js")); + assert!(!settings + .script_overrides + .is_overridden("/js/other-script.js")); + } + + #[test] + fn test_script_overrides_default_empty() { + let toml_str = crate_test_settings_str(); + let settings = Settings::from_toml(&toml_str).expect("should parse TOML"); + + // When not specified, script_overrides should default to empty + assert_eq!(settings.script_overrides.paths.len(), 0); + assert!(!settings + .script_overrides + .is_overridden("/.static/prebid/1.0.8/prebid.min.js")); + } } diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 21cbcaa..ce03542 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -15,6 +15,7 @@ use trusted_server_common::publisher::{handle_publisher_request, handle_tsjs_dyn use trusted_server_common::request_signing::{ handle_deactivate_key, handle_jwks_endpoint, handle_rotate_key, handle_verify_signature, }; +use trusted_server_common::script_overrides::handle_script_override; use trusted_server_common::settings::Settings; use trusted_server_common::settings_data::get_settings; @@ -56,6 +57,12 @@ async fn route_request( let path = req.get_path().to_string(); let method = req.get_method().clone(); + // Check if this path should return an empty script override + if settings.script_overrides.is_overridden(&path) { + log::info!("Returning script override for path: {}", path); + return Ok(handle_script_override(&settings)); + } + // Match known routes and handle them let result = match (method, path.as_str()) { // Serve the tsjs library diff --git a/trusted-server.toml b/trusted-server.toml index 6149565..2e1882e 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -56,3 +56,11 @@ rewrite_scripts = true # exclude_domains = [ # "*.edgecompute.app", # ] + +# Script Override Configuration +# Define paths that should return empty JavaScript responses +# Useful for blocking specific script versions or third-party scripts +[script_overrides] +paths = [ + "/.static/prebid/1.0.8/prebid.min.js", +]