Skip to content

Commit 8dc26fa

Browse files
Honor publisher Host override and bypass IO for SVG assets (#787)
1 parent 1e03a9e commit 8dc26fa

7 files changed

Lines changed: 154 additions & 8 deletions

File tree

crates/trusted-server-adapter-fastly/src/platform.rs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ pub struct FastlyPlatformBackend;
157157
fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> {
158158
BackendConfig::new(&spec.scheme, &spec.host)
159159
.port(spec.port)
160+
.host_header_override(spec.host_header_override.as_deref())
160161
.certificate_check(spec.certificate_check)
161162
.first_byte_timeout(spec.first_byte_timeout)
162163
}
@@ -308,7 +309,16 @@ fn edge_request_to_fastly(
308309
let (parts, body) = request.into_parts();
309310
let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string());
310311
for (name, value) in parts.headers.iter() {
311-
fastly_req.append_header(name.as_str(), value.as_bytes());
312+
// `fastly::Request::new` derives a Host header from the request URI, so
313+
// appending the edge request's own Host would leave a duplicate. Replace
314+
// it instead to keep the in-memory request well-formed. The Host actually
315+
// sent on the wire is still governed by the backend's `override_host`
316+
// (see `BackendConfig::ensure`), which forces the value regardless.
317+
if name == edgezero_core::http::header::HOST {
318+
fastly_req.set_header(name.as_str(), value.as_bytes());
319+
} else {
320+
fastly_req.append_header(name.as_str(), value.as_bytes());
321+
}
312322
}
313323
match body {
314324
edgezero_core::body::Body::Once(bytes) => {
@@ -610,6 +620,24 @@ mod tests {
610620
Arc::new(NoopKvStore)
611621
}
612622

623+
#[test]
624+
fn edge_request_to_fastly_replaces_url_derived_host_header() {
625+
let request = request_builder()
626+
.method("GET")
627+
.uri("https://origin.example.com/")
628+
.header(edgezero_core::http::header::HOST, "www.example.com")
629+
.body(Body::empty())
630+
.expect("should build request");
631+
632+
let fastly_req = edge_request_to_fastly(request).expect("should convert request");
633+
634+
assert_eq!(
635+
fastly_req.get_header_str(fastly::http::header::HOST),
636+
Some("www.example.com"),
637+
"should replace the URL-derived Host instead of appending a duplicate"
638+
);
639+
}
640+
613641
// --- FastlyPlatformBackend::predict_name --------------------------------
614642

615643
#[test]
@@ -619,6 +647,7 @@ mod tests {
619647
scheme: "https".to_string(),
620648
host: "origin.example.com".to_string(),
621649
port: None,
650+
host_header_override: None,
622651
certificate_check: true,
623652
first_byte_timeout: Duration::from_secs(15),
624653
};
@@ -633,13 +662,36 @@ mod tests {
633662
);
634663
}
635664

665+
#[test]
666+
fn predict_name_includes_host_header_override_suffix() {
667+
let backend = FastlyPlatformBackend;
668+
let spec = PlatformBackendSpec {
669+
scheme: "https".to_string(),
670+
host: "origin.example.com".to_string(),
671+
port: None,
672+
host_header_override: Some("www.example.com".to_string()),
673+
certificate_check: true,
674+
first_byte_timeout: Duration::from_secs(15),
675+
};
676+
677+
let name = backend
678+
.predict_name(&spec)
679+
.expect("should compute backend name for host header override");
680+
681+
assert_eq!(
682+
name, "backend_https_origin_example_com_443_oh_www_example_com_t15000",
683+
"should match BackendConfig naming convention with host header override"
684+
);
685+
}
686+
636687
#[test]
637688
fn predict_name_includes_nocert_suffix_when_cert_check_disabled() {
638689
let backend = FastlyPlatformBackend;
639690
let spec = PlatformBackendSpec {
640691
scheme: "https".to_string(),
641692
host: "origin.example.com".to_string(),
642693
port: None,
694+
host_header_override: None,
643695
certificate_check: false,
644696
first_byte_timeout: Duration::from_secs(15),
645697
};
@@ -661,6 +713,7 @@ mod tests {
661713
scheme: "https".to_string(),
662714
host: String::new(),
663715
port: None,
716+
host_header_override: None,
664717
certificate_check: true,
665718
first_byte_timeout: Duration::from_secs(15),
666719
};
@@ -677,6 +730,7 @@ mod tests {
677730
scheme: "https".to_string(),
678731
host: "origin.example.com".to_string(),
679732
port: None,
733+
host_header_override: None,
680734
certificate_check: true,
681735
first_byte_timeout: Duration::from_millis(2000),
682736
};

crates/trusted-server-core/src/integrations/datadome/protection.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ impl DataDomeIntegration {
156156
scheme: parsed.scheme().to_string(),
157157
host: host.to_string(),
158158
port: parsed.port(),
159+
host_header_override: None,
159160
certificate_check: true,
160161
first_byte_timeout: Duration::from_millis(u64::from(self.config.timeout_ms)),
161162
};

crates/trusted-server-core/src/integrations/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ pub(crate) fn ensure_integration_backend(
7171
})?
7272
.to_string(),
7373
port: parsed.port(),
74+
host_header_override: None,
7475
certificate_check: true,
7576
first_byte_timeout: first_byte_timeout.unwrap_or_else(|| Duration::from_secs(15)),
7677
})

crates/trusted-server-core/src/platform/test_support.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,7 @@ mod tests {
807807
scheme: "https".to_string(),
808808
host: "example.com".to_string(),
809809
port: None,
810+
host_header_override: None,
810811
certificate_check: true,
811812
first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT,
812813
};

crates/trusted-server-core/src/platform/types.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ pub struct PlatformBackendSpec {
133133
pub host: String,
134134
/// Explicit port, or `None` to use the scheme default.
135135
pub port: Option<u16>,
136+
/// Optional outbound Host header to send to the backend origin.
137+
pub host_header_override: Option<String>,
136138
/// Whether to verify the TLS certificate.
137139
pub certificate_check: bool,
138140
/// Maximum time to wait for the first response byte.

crates/trusted-server-core/src/proxy.rs

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -749,8 +749,8 @@ fn build_asset_proxy_target_url(
749749
Ok(target_url)
750750
}
751751

752-
fn asset_path_skips_image_optimizer(target_url: &url::Url) -> bool {
753-
let lower_path = target_url.path().to_ascii_lowercase();
752+
fn asset_path_skips_image_optimizer(path: &str) -> bool {
753+
let lower_path = path.to_ascii_lowercase();
754754
lower_path.ends_with(".svg") || lower_path.ends_with(".svgz")
755755
}
756756

@@ -1028,12 +1028,15 @@ pub async fn handle_asset_proxy_request(
10281028
req: Request<EdgeBody>,
10291029
route: &ProxyAssetRoute,
10301030
) -> Result<AssetProxyResponse, Report<TrustedServerError>> {
1031+
let incoming_path = req.uri().path();
10311032
let incoming_query = req.uri().query().unwrap_or("");
1032-
let mut target_url = build_asset_proxy_target_url(route, req.uri().path(), incoming_query)?;
1033-
let skip_image_optimizer = asset_path_skips_image_optimizer(&target_url);
1033+
let mut target_url = build_asset_proxy_target_url(route, incoming_path, incoming_query)?;
1034+
let skip_image_optimizer = asset_path_skips_image_optimizer(incoming_path)
1035+
|| asset_path_skips_image_optimizer(target_url.path());
10341036
let image_optimizer = if skip_image_optimizer {
10351037
log::debug!(
1036-
"Skipping Image Optimizer for unsupported SVG asset path: {}",
1038+
"Skipping Image Optimizer for unsupported SVG asset path: incoming={}, target={}",
1039+
incoming_path,
10371040
target_url.path()
10381041
);
10391042
None
@@ -1058,6 +1061,7 @@ pub async fn handle_asset_proxy_request(
10581061
scheme: scheme.to_string(),
10591062
host: host.to_string(),
10601063
port: target_url.port(),
1064+
host_header_override: None,
10611065
certificate_check: settings.proxy.certificate_check,
10621066
first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT,
10631067
})
@@ -1241,6 +1245,7 @@ async fn proxy_with_redirects(
12411245
scheme: scheme.clone(),
12421246
host: host.to_string(),
12431247
port: parsed_url.port(),
1248+
host_header_override: None,
12441249
certificate_check: settings.proxy.certificate_check,
12451250
first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT,
12461251
})
@@ -3094,7 +3099,7 @@ mod tests {
30943099
] {
30953100
let target_url = url::Url::parse(url).expect("should parse target URL");
30963101
assert!(
3097-
asset_path_skips_image_optimizer(&target_url),
3102+
asset_path_skips_image_optimizer(target_url.path()),
30983103
"should skip Image Optimizer for {url}"
30993104
);
31003105
}
@@ -3109,7 +3114,7 @@ mod tests {
31093114
] {
31103115
let target_url = url::Url::parse(url).expect("should parse target URL");
31113116
assert!(
3112-
!asset_path_skips_image_optimizer(&target_url),
3117+
!asset_path_skips_image_optimizer(target_url.path()),
31133118
"should allow Image Optimizer for {url}"
31143119
);
31153120
}
@@ -3662,6 +3667,49 @@ mod tests {
36623667
);
36633668
}
36643669

3670+
#[tokio::test]
3671+
async fn handle_asset_proxy_request_skips_image_optimizer_for_incoming_svg() {
3672+
let stub = Arc::new(StubHttpClient::new());
3673+
stub.push_response(200, b"ok".to_vec());
3674+
let services = build_services_with_http_client(
3675+
Arc::clone(&stub) as Arc<dyn crate::platform::PlatformHttpClient>
3676+
);
3677+
let mut settings = create_test_settings();
3678+
let mut profile_set = test_profile_set();
3679+
profile_set.unknown_profile = UnknownProfilePolicy::Reject;
3680+
settings.image_optimizer = ImageOptimizerSettings {
3681+
profile_sets: HashMap::from([("default_images".to_string(), profile_set)]),
3682+
};
3683+
let req = build_http_request(
3684+
Method::GET,
3685+
"https://www.example.com/.image/object-id/logo.svg?profile=unknown&ar=1-1",
3686+
);
3687+
let mut route = ProxyAssetRoute::new("/.image/", "https://assets.example.com");
3688+
route.path_pattern = Some(r"^/\.image/([^/]+)/[^/]+\.([^/.]+)$".to_string());
3689+
route.target_path = Some("/image/upload/$1".to_string());
3690+
route.image_optimizer = Some(AssetImageOptimizerConfig {
3691+
enabled: true,
3692+
region: "us_east".to_string(),
3693+
profile_set: "default_images".to_string(),
3694+
origin_query: None,
3695+
});
3696+
3697+
handle_asset_proxy_request(&settings, &services, req, &route)
3698+
.await
3699+
.expect("should proxy incoming SVG asset without Image Optimizer profile parsing");
3700+
3701+
assert_eq!(
3702+
stub.recorded_request_uris(),
3703+
vec!["https://assets.example.com/image/upload/object-id"],
3704+
"should strip profile-table query even when SVG target path omits extension"
3705+
);
3706+
assert_eq!(
3707+
stub.recorded_image_optimizer_options(),
3708+
vec![None],
3709+
"incoming SVG assets should bypass Image Optimizer metadata"
3710+
);
3711+
}
3712+
36653713
#[tokio::test]
36663714
async fn handle_asset_proxy_request_skips_s3_preflight_for_svg_image_optimizer_routes() {
36673715
let stub = Arc::new(StubHttpClient::new());

crates/trusted-server-core/src/publisher.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ pub async fn handle_publisher_request(
499499
scheme: origin_scheme.clone(),
500500
host: origin_host_without_port.to_string(),
501501
port: parsed_origin.port(),
502+
host_header_override: settings.publisher.origin_host_header_override.clone(),
502503
certificate_check: settings.proxy.certificate_check,
503504
first_byte_timeout: DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT,
504505
})
@@ -1315,6 +1316,44 @@ mod tests {
13151316
);
13161317
}
13171318

1319+
#[tokio::test]
1320+
async fn publisher_request_sends_configured_host_header_override() {
1321+
let mut settings = create_test_settings();
1322+
settings.publisher.origin_host_header_override = Some("www.example.com".to_string());
1323+
let registry =
1324+
IntegrationRegistry::new(&settings).expect("should create integration registry");
1325+
let stub = Arc::new(StubHttpClient::new());
1326+
stub.push_response(200, b"origin response".to_vec());
1327+
let services = build_services_with_http_client(
1328+
Arc::clone(&stub) as Arc<dyn crate::platform::PlatformHttpClient>
1329+
);
1330+
let req = HttpRequest::builder()
1331+
.method(Method::GET)
1332+
.uri("https://publisher.example/page")
1333+
.header(header::HOST, "publisher.example")
1334+
.body(EdgeBody::empty())
1335+
.expect("should build request");
1336+
1337+
let _ = handle_publisher_request(&settings, &registry, &services, req)
1338+
.await
1339+
.expect("should proxy publisher request");
1340+
1341+
let recorded_headers = stub.recorded_request_headers();
1342+
let outbound_headers = recorded_headers
1343+
.first()
1344+
.expect("should record one outbound request");
1345+
let outbound_host = outbound_headers
1346+
.iter()
1347+
.find(|(name, _)| name.eq_ignore_ascii_case("host"))
1348+
.map(|(_, value)| value.as_str());
1349+
1350+
assert_eq!(
1351+
outbound_host,
1352+
Some("www.example.com"),
1353+
"should send configured host override to outbound request"
1354+
);
1355+
}
1356+
13181357
#[test]
13191358
fn stream_publisher_body_preserves_gzip_round_trip() {
13201359
use flate2::write::GzEncoder;

0 commit comments

Comments
 (0)