Skip to content
56 changes: 55 additions & 1 deletion crates/trusted-server-adapter-fastly/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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());
}
Comment thread
ChristianPavilonis marked this conversation as resolved.
}
match body {
edgezero_core::body::Body::Once(bytes) => {
Expand Down Expand Up @@ -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]
Expand All @@ -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),
};
Expand All @@ -633,13 +662,36 @@ 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;
let spec = PlatformBackendSpec {
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),
};
Expand All @@ -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),
};
Expand All @@ -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),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
})
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/platform/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
2 changes: 2 additions & 0 deletions crates/trusted-server-core/src/platform/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ pub struct PlatformBackendSpec {
pub host: String,
/// Explicit port, or `None` to use the scheme default.
pub port: Option<u16>,
/// Optional outbound Host header to send to the backend origin.
pub host_header_override: Option<String>,
/// Whether to verify the TLS certificate.
pub certificate_check: bool,
/// Maximum time to wait for the first response byte.
Expand Down
62 changes: 55 additions & 7 deletions crates/trusted-server-core/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -1028,12 +1028,15 @@ pub async fn handle_asset_proxy_request(
req: Request<EdgeBody>,
route: &ProxyAssetRoute,
) -> Result<AssetProxyResponse, Report<TrustedServerError>> {
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
Comment thread
ChristianPavilonis marked this conversation as resolved.
Expand All @@ -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,
})
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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}"
);
}
Expand All @@ -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}"
);
}
Expand Down Expand Up @@ -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<dyn crate::platform::PlatformHttpClient>
);
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());
Expand Down
39 changes: 39 additions & 0 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment thread
ChristianPavilonis marked this conversation as resolved.
certificate_check: settings.proxy.certificate_check,
first_byte_timeout: DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT,
})
Expand Down Expand Up @@ -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<dyn crate::platform::PlatformHttpClient>
);
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, &registry, &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;
Expand Down
Loading