diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 7e96a545..d73c4365 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -157,6 +157,7 @@ pub struct FastlyPlatformBackend; fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { BackendConfig::new(&spec.scheme, &spec.host) .port(spec.port) + .host_header_override(spec.host_header_override.as_deref()) .certificate_check(spec.certificate_check) .first_byte_timeout(spec.first_byte_timeout) } @@ -308,7 +309,16 @@ fn edge_request_to_fastly( let (parts, body) = request.into_parts(); let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string()); for (name, value) in parts.headers.iter() { - fastly_req.append_header(name.as_str(), value.as_bytes()); + // `fastly::Request::new` derives a Host header from the request URI, so + // appending the edge request's own Host would leave a duplicate. Replace + // it instead to keep the in-memory request well-formed. The Host actually + // sent on the wire is still governed by the backend's `override_host` + // (see `BackendConfig::ensure`), which forces the value regardless. + if name == edgezero_core::http::header::HOST { + fastly_req.set_header(name.as_str(), value.as_bytes()); + } else { + fastly_req.append_header(name.as_str(), value.as_bytes()); + } } match body { edgezero_core::body::Body::Once(bytes) => { @@ -610,6 +620,24 @@ mod tests { Arc::new(NoopKvStore) } + #[test] + fn edge_request_to_fastly_replaces_url_derived_host_header() { + let request = request_builder() + .method("GET") + .uri("https://origin.example.com/") + .header(edgezero_core::http::header::HOST, "www.example.com") + .body(Body::empty()) + .expect("should build request"); + + let fastly_req = edge_request_to_fastly(request).expect("should convert request"); + + assert_eq!( + fastly_req.get_header_str(fastly::http::header::HOST), + Some("www.example.com"), + "should replace the URL-derived Host instead of appending a duplicate" + ); + } + // --- FastlyPlatformBackend::predict_name -------------------------------- #[test] @@ -619,6 +647,7 @@ mod tests { scheme: "https".to_string(), host: "origin.example.com".to_string(), port: None, + host_header_override: None, certificate_check: true, first_byte_timeout: Duration::from_secs(15), }; @@ -633,6 +662,28 @@ mod tests { ); } + #[test] + fn predict_name_includes_host_header_override_suffix() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "origin.example.com".to_string(), + port: None, + host_header_override: Some("www.example.com".to_string()), + certificate_check: true, + first_byte_timeout: Duration::from_secs(15), + }; + + let name = backend + .predict_name(&spec) + .expect("should compute backend name for host header override"); + + assert_eq!( + name, "backend_https_origin_example_com_443_oh_www_example_com_t15000", + "should match BackendConfig naming convention with host header override" + ); + } + #[test] fn predict_name_includes_nocert_suffix_when_cert_check_disabled() { let backend = FastlyPlatformBackend; @@ -640,6 +691,7 @@ mod tests { scheme: "https".to_string(), host: "origin.example.com".to_string(), port: None, + host_header_override: None, certificate_check: false, first_byte_timeout: Duration::from_secs(15), }; @@ -661,6 +713,7 @@ mod tests { scheme: "https".to_string(), host: String::new(), port: None, + host_header_override: None, certificate_check: true, first_byte_timeout: Duration::from_secs(15), }; @@ -677,6 +730,7 @@ mod tests { scheme: "https".to_string(), host: "origin.example.com".to_string(), port: None, + host_header_override: None, certificate_check: true, first_byte_timeout: Duration::from_millis(2000), }; diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index 8512bcc9..96eade32 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -156,6 +156,7 @@ impl DataDomeIntegration { scheme: parsed.scheme().to_string(), host: host.to_string(), port: parsed.port(), + host_header_override: None, certificate_check: true, first_byte_timeout: Duration::from_millis(u64::from(self.config.timeout_ms)), }; diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 297cf9d0..192857af 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -71,6 +71,7 @@ pub(crate) fn ensure_integration_backend( })? .to_string(), port: parsed.port(), + host_header_override: None, certificate_check: true, first_byte_timeout: first_byte_timeout.unwrap_or_else(|| Duration::from_secs(15)), }) diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 36182fea..493135ce 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -807,6 +807,7 @@ mod tests { scheme: "https".to_string(), host: "example.com".to_string(), port: None, + host_header_override: None, certificate_check: true, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, }; diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 33250d1d..25068218 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -133,6 +133,8 @@ pub struct PlatformBackendSpec { pub host: String, /// Explicit port, or `None` to use the scheme default. pub port: Option, + /// Optional outbound Host header to send to the backend origin. + pub host_header_override: Option, /// Whether to verify the TLS certificate. pub certificate_check: bool, /// Maximum time to wait for the first response byte. diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index bcd70e18..98528524 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -749,8 +749,8 @@ fn build_asset_proxy_target_url( Ok(target_url) } -fn asset_path_skips_image_optimizer(target_url: &url::Url) -> bool { - let lower_path = target_url.path().to_ascii_lowercase(); +fn asset_path_skips_image_optimizer(path: &str) -> bool { + let lower_path = path.to_ascii_lowercase(); lower_path.ends_with(".svg") || lower_path.ends_with(".svgz") } @@ -1028,12 +1028,15 @@ pub async fn handle_asset_proxy_request( req: Request, route: &ProxyAssetRoute, ) -> Result> { + let incoming_path = req.uri().path(); let incoming_query = req.uri().query().unwrap_or(""); - let mut target_url = build_asset_proxy_target_url(route, req.uri().path(), incoming_query)?; - let skip_image_optimizer = asset_path_skips_image_optimizer(&target_url); + let mut target_url = build_asset_proxy_target_url(route, incoming_path, incoming_query)?; + let skip_image_optimizer = asset_path_skips_image_optimizer(incoming_path) + || asset_path_skips_image_optimizer(target_url.path()); let image_optimizer = if skip_image_optimizer { log::debug!( - "Skipping Image Optimizer for unsupported SVG asset path: {}", + "Skipping Image Optimizer for unsupported SVG asset path: incoming={}, target={}", + incoming_path, target_url.path() ); None @@ -1058,6 +1061,7 @@ pub async fn handle_asset_proxy_request( scheme: scheme.to_string(), host: host.to_string(), port: target_url.port(), + host_header_override: None, certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, }) @@ -1241,6 +1245,7 @@ async fn proxy_with_redirects( scheme: scheme.clone(), host: host.to_string(), port: parsed_url.port(), + host_header_override: None, certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, }) @@ -3094,7 +3099,7 @@ mod tests { ] { let target_url = url::Url::parse(url).expect("should parse target URL"); assert!( - asset_path_skips_image_optimizer(&target_url), + asset_path_skips_image_optimizer(target_url.path()), "should skip Image Optimizer for {url}" ); } @@ -3109,7 +3114,7 @@ mod tests { ] { let target_url = url::Url::parse(url).expect("should parse target URL"); assert!( - !asset_path_skips_image_optimizer(&target_url), + !asset_path_skips_image_optimizer(target_url.path()), "should allow Image Optimizer for {url}" ); } @@ -3662,6 +3667,49 @@ mod tests { ); } + #[tokio::test] + async fn handle_asset_proxy_request_skips_image_optimizer_for_incoming_svg() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let mut settings = create_test_settings(); + let mut profile_set = test_profile_set(); + profile_set.unknown_profile = UnknownProfilePolicy::Reject; + settings.image_optimizer = ImageOptimizerSettings { + profile_sets: HashMap::from([("default_images".to_string(), profile_set)]), + }; + let req = build_http_request( + Method::GET, + "https://www.example.com/.image/object-id/logo.svg?profile=unknown&ar=1-1", + ); + let mut route = ProxyAssetRoute::new("/.image/", "https://assets.example.com"); + route.path_pattern = Some(r"^/\.image/([^/]+)/[^/]+\.([^/.]+)$".to_string()); + route.target_path = Some("/image/upload/$1".to_string()); + route.image_optimizer = Some(AssetImageOptimizerConfig { + enabled: true, + region: "us_east".to_string(), + profile_set: "default_images".to_string(), + origin_query: None, + }); + + handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy incoming SVG asset without Image Optimizer profile parsing"); + + assert_eq!( + stub.recorded_request_uris(), + vec!["https://assets.example.com/image/upload/object-id"], + "should strip profile-table query even when SVG target path omits extension" + ); + assert_eq!( + stub.recorded_image_optimizer_options(), + vec![None], + "incoming SVG assets should bypass Image Optimizer metadata" + ); + } + #[tokio::test] async fn handle_asset_proxy_request_skips_s3_preflight_for_svg_image_optimizer_routes() { let stub = Arc::new(StubHttpClient::new()); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index db8a1778..8fb2212f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -499,6 +499,7 @@ pub async fn handle_publisher_request( scheme: origin_scheme.clone(), host: origin_host_without_port.to_string(), port: parsed_origin.port(), + host_header_override: settings.publisher.origin_host_header_override.clone(), certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT, }) @@ -1315,6 +1316,44 @@ mod tests { ); } + #[tokio::test] + async fn publisher_request_sends_configured_host_header_override() { + let mut settings = create_test_settings(); + settings.publisher.origin_host_header_override = Some("www.example.com".to_string()); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"origin response".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let _ = handle_publisher_request(&settings, ®istry, &services, req) + .await + .expect("should proxy publisher request"); + + let recorded_headers = stub.recorded_request_headers(); + let outbound_headers = recorded_headers + .first() + .expect("should record one outbound request"); + let outbound_host = outbound_headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("host")) + .map(|(_, value)| value.as_str()); + + assert_eq!( + outbound_host, + Some("www.example.com"), + "should send configured host override to outbound request" + ); + } + #[test] fn stream_publisher_body_preserves_gzip_round_trip() { use flate2::write::GzEncoder;