From b4f13f9cf69de490341b60adf3d2e684ee941a95 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 11 May 2026 12:14:30 -0500 Subject: [PATCH 1/4] Add multi-backend asset proxy routing --- .../trusted-server-adapter-fastly/src/main.rs | 118 +-- .../src/route_tests.rs | 433 ++++++++++- crates/trusted-server-core/src/backend.rs | 77 +- .../src/platform/test_support.rs | 48 +- crates/trusted-server-core/src/proxy.rs | 436 ++++++++++- crates/trusted-server-core/src/publisher.rs | 26 +- crates/trusted-server-core/src/settings.rs | 682 +++++++++++++++++- ...-04-28-multi-backend-asset-proxy-design.md | 660 +++++++++++++++++ trusted-server.toml | 29 + 9 files changed, 2423 insertions(+), 86 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-28-multi-backend-asset-proxy-design.md diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index d2b905d3..3d8942f3 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -15,8 +15,8 @@ use trusted_server_core::geo::GeoInfo; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ - handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, - handle_first_party_proxy_sign, + handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, + handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, PublisherResponse, @@ -207,6 +207,10 @@ async fn route_request( let path = req.get_path().to_string(); let method = req.get_method().clone(); + let matched_asset_route = matches!(method, Method::GET | Method::HEAD) + .then(|| settings.asset_route_for_path(&path)) + .flatten(); + // Match known routes and handle them let result = match (method, path.as_str()) { // Serve the tsjs library @@ -263,62 +267,72 @@ async fn route_request( })) }), - // No known route matched, proxy to publisher origin as fallback + // No known route matched, proxy to an asset origin or publisher origin as fallback _ => { - log::info!( - "No known route matched for path: {}, proxying to publisher origin", - path - ); - - match runtime_services_for_consent_route(settings, runtime_services) { - Ok(publisher_services) => { - match handle_publisher_request( - settings, - integration_registry, - &publisher_services, - req, - ) { - Ok(PublisherResponse::Stream { - mut response, - body, - params, - }) => { - // Streaming path: finalize headers, then stream body to client. - finalize_response(settings, geo_info.as_ref(), &mut response); - let mut streaming_body = response.stream_to_client(); - if let Err(e) = stream_publisher_body( + if let Some(asset_route) = matched_asset_route { + log::info!( + "No explicit route matched for path: {}, proxying via asset route prefix {} to {}", + path, + asset_route.prefix, + asset_route.origin_url + ); + handle_asset_proxy_request(settings, runtime_services, req, asset_route).await + } else { + log::info!( + "No known route matched for path: {}, proxying to publisher origin", + path + ); + + match runtime_services_for_consent_route(settings, runtime_services) { + Ok(publisher_services) => { + match handle_publisher_request( + settings, + integration_registry, + &publisher_services, + req, + ) { + Ok(PublisherResponse::Stream { + mut response, body, - &mut streaming_body, - ¶ms, - settings, - integration_registry, - ) { - // Headers already committed. Log and abort — client - // sees a truncated response. Standard proxy behavior. - log::error!("Streaming processing failed: {e:?}"); - drop(streaming_body); - } else if let Err(e) = streaming_body.finish() { - log::error!("Failed to finish streaming body: {e}"); + params, + }) => { + // Streaming path: finalize headers, then stream body to client. + finalize_response(settings, geo_info.as_ref(), &mut response); + let mut streaming_body = response.stream_to_client(); + if let Err(e) = stream_publisher_body( + body, + &mut streaming_body, + ¶ms, + settings, + integration_registry, + ) { + // Headers already committed. Log and abort — client + // sees a truncated response. Standard proxy behavior. + log::error!("Streaming processing failed: {e:?}"); + drop(streaming_body); + } else if let Err(e) = streaming_body.finish() { + log::error!("Failed to finish streaming body: {e}"); + } + // Response already sent via stream_to_client() + return None; + } + Ok(PublisherResponse::PassThrough { mut response, body }) => { + // Binary pass-through: reattach body and send via send_to_client(). + // This preserves Content-Length and avoids chunked encoding overhead. + // Fastly streams the body from its internal buffer — no WASM + // memory buffering occurs. + response.set_body(body); + Ok(response) + } + Ok(PublisherResponse::Buffered(response)) => Ok(response), + Err(e) => { + log::error!("Failed to proxy to publisher origin: {:?}", e); + Err(e) } - // Response already sent via stream_to_client() - return None; - } - Ok(PublisherResponse::PassThrough { mut response, body }) => { - // Binary pass-through: reattach body and send via send_to_client(). - // This preserves Content-Length and avoids chunked encoding overhead. - // Fastly streams the body from its internal buffer — no WASM - // memory buffering occurs. - response.set_body(body); - Ok(response) - } - Ok(PublisherResponse::Buffered(response)) => Ok(response), - Err(e) => { - log::error!("Failed to proxy to publisher origin: {:?}", e); - Err(e) } } + Err(e) => Err(e), } - Err(e) => Err(e), } } }; diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 0fd0113f..64f3cec9 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -1,9 +1,11 @@ use std::net::IpAddr; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::response_builder as edge_response_builder; use edgezero_core::key_value_store::NoopKvStore; use error_stack::Report; -use fastly::http::StatusCode; +use fastly::http::{header, Method, StatusCode}; use fastly::Request; use trusted_server_core::auction::build_orchestrator; use trusted_server_core::integrations::IntegrationRegistry; @@ -14,7 +16,7 @@ use trusted_server_core::platform::{ StoreName, }; use trusted_server_core::request_signing::JWKS_CONFIG_STORE_NAME; -use trusted_server_core::settings::Settings; +use trusted_server_core::settings::{ProxyAssetRoute, Settings}; use super::route_request; @@ -85,6 +87,92 @@ impl PlatformBackend for NoopBackend { struct NoopHttpClient; +struct RecordingHttpClient { + calls: Mutex>, + response_status: StatusCode, + response_headers: Vec<(String, String)>, +} + +impl RecordingHttpClient { + fn new(response_status: StatusCode) -> Self { + Self { + calls: Mutex::new(Vec::new()), + response_status, + response_headers: Vec::new(), + } + } + + fn with_response_headers( + mut self, + headers: Vec<(impl Into, impl Into)>, + ) -> Self { + self.response_headers = headers + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(); + self + } +} + +struct RecordedHttpCall { + method: Method, + uri: String, + backend_name: String, +} + +struct FixedBackend; + +impl PlatformBackend for FixedBackend { + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + Ok(format!("{}-{}", spec.scheme, spec.host)) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for RecordingHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.calls + .lock() + .expect("should lock calls") + .push(RecordedHttpCall { + method: request.request.method().clone(), + uri: request.request.uri().to_string(), + backend_name: request.backend_name, + }); + + let mut builder = edge_response_builder().status(self.response_status); + for (name, value) in &self.response_headers { + builder = builder.header(name, value); + } + let edge_response = builder + .body(EdgeBody::from(Vec::new())) + .map_err(|_| Report::new(PlatformError::HttpClient))?; + + Ok(PlatformResponse::new(edge_response)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } +} + #[async_trait::async_trait(?Send)] impl PlatformHttpClient for NoopHttpClient { async fn send( @@ -163,12 +251,24 @@ fn create_test_settings() -> Settings { } fn test_runtime_services(req: &Request) -> RuntimeServices { + test_runtime_services_with_http_client( + req, + Arc::new(NoopBackend), + Arc::new(NoopHttpClient) as Arc, + ) +} + +fn test_runtime_services_with_http_client( + req: &Request, + backend: Arc, + http_client: Arc, +) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(StubJwksConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(NoopKvStore) as Arc) - .backend(Arc::new(NoopBackend)) - .http_client(Arc::new(NoopHttpClient)) + .backend(backend) + .http_client(http_client) .geo(Arc::new(NoopGeo)) .client_info(ClientInfo { client_ip: req.get_client_ip_addr(), @@ -249,3 +349,326 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { "should scope consent store failures to the consent-dependent routes" ); } + +#[test] +fn asset_routes_bypass_publisher_consent_dependencies() { + let mut settings = create_test_settings(); + settings.proxy.asset_routes = vec![ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }]; + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let asset_req = Request::get("https://test.com/.images/logo.png?auto=webp"); + let asset_services = test_runtime_services(&asset_req); + let asset_resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &asset_services, + asset_req, + )) + .expect("should return an error response for asset proxy requests"); + assert_eq!( + asset_resp.get_status(), + StatusCode::BAD_GATEWAY, + "should bypass publisher consent dependencies and fail only on the missing upstream client" + ); +} + +#[test] +fn asset_origin_failure_does_not_fall_back_to_publisher_origin() { + let mut settings = create_test_settings(); + settings.proxy.asset_routes = vec![ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }]; + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let req = Request::get("https://test.com/.images/logo.png"); + let http_client = Arc::new(RecordingHttpClient::new(StatusCode::OK)); + let services = test_runtime_services_with_http_client( + &req, + Arc::new(NoopBackend), + Arc::clone(&http_client) as Arc, + ); + + let resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + )) + .expect("should return an error response for failed asset origin"); + + assert_eq!( + resp.get_status(), + StatusCode::BAD_GATEWAY, + "should stop asset-origin backend failures at the asset proxy path" + ); + assert!( + http_client + .calls + .lock() + .expect("should lock recorded calls") + .is_empty(), + "should not invoke the publisher origin when asset backend registration fails" + ); +} + +#[test] +fn asset_routes_proxy_head_requests() { + let mut settings = create_test_settings(); + settings.proxy.asset_routes = vec![ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }]; + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let req = Request::head("https://test.com/.images/logo.png"); + let http_client = Arc::new(RecordingHttpClient::new(StatusCode::NO_CONTENT)); + let services = test_runtime_services_with_http_client( + &req, + Arc::new(FixedBackend), + Arc::clone(&http_client) as Arc, + ); + + let resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + )) + .expect("should route HEAD asset request"); + + assert_eq!( + resp.get_status(), + StatusCode::NO_CONTENT, + "should pass through asset-origin HEAD response status" + ); + let calls = http_client + .calls + .lock() + .expect("should lock recorded calls"); + assert_eq!(calls.len(), 1, "should send exactly one asset request"); + assert_eq!( + calls[0].method, + Method::HEAD, + "should forward HEAD upstream" + ); + assert!( + calls[0].backend_name.contains("assets.example.com"), + "should send to the asset backend, got {}", + calls[0].backend_name + ); +} + +#[test] +fn asset_routes_ignore_query_string_for_matching() { + let mut settings = create_test_settings(); + settings.proxy.asset_routes = vec![ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }]; + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let req = Request::get("https://test.com/.images/logo.png?auto=webp"); + let http_client = Arc::new(RecordingHttpClient::new(StatusCode::OK)); + let services = test_runtime_services_with_http_client( + &req, + Arc::new(FixedBackend), + Arc::clone(&http_client) as Arc, + ); + + let resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + )) + .expect("should route asset request with query string"); + + assert_eq!( + resp.get_status(), + StatusCode::OK, + "should match by path only" + ); + let calls = http_client + .calls + .lock() + .expect("should lock recorded calls"); + assert_eq!(calls.len(), 1, "should send exactly one asset request"); + assert!( + calls[0].uri.ends_with("/.images/logo.png?auto=webp"), + "should preserve query on the upstream asset request, got {}", + calls[0].uri + ); +} + +#[test] +fn asset_routes_pass_redirect_responses_through() { + let mut settings = create_test_settings(); + settings.proxy.asset_routes = vec![ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }]; + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let req = Request::get("https://test.com/.images/logo.png"); + let http_client = Arc::new( + RecordingHttpClient::new(StatusCode::FOUND).with_response_headers(vec![( + header::LOCATION.as_str(), + "https://cdn.example.com/logo.png", + )]), + ); + let services = test_runtime_services_with_http_client( + &req, + Arc::new(FixedBackend), + Arc::clone(&http_client) as Arc, + ); + + let resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + )) + .expect("should route redirecting asset request"); + + assert_eq!( + resp.get_status(), + StatusCode::FOUND, + "should pass redirect status through without following it" + ); + assert_eq!( + resp.get_header_str(header::LOCATION), + Some("https://cdn.example.com/logo.png"), + "should preserve asset-origin redirect location" + ); +} + +#[test] +fn asset_routes_skip_non_get_head_requests() { + let mut settings = create_test_settings(); + settings.proxy.asset_routes = vec![ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }]; + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let req = Request::post("https://test.com/.images/logo.png"); + let http_client = Arc::new(RecordingHttpClient::new(StatusCode::OK)); + let services = test_runtime_services_with_http_client( + &req, + Arc::new(FixedBackend), + Arc::clone(&http_client) as Arc, + ); + + let resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + )) + .expect("should route non-asset POST request"); + + assert_ne!( + resp.get_status(), + StatusCode::OK, + "should not return the asset-origin response for POST requests" + ); + assert!( + http_client + .calls + .lock() + .expect("should lock recorded calls") + .is_empty(), + "should not send POST requests through asset routing" + ); +} + +#[test] +fn built_in_routes_take_precedence_over_asset_routes() { + let mut settings = create_test_settings(); + settings.proxy.asset_routes = vec![ProxyAssetRoute { + prefix: "/.well-known/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }]; + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let req = Request::get("https://test.com/.well-known/trusted-server.json"); + let services = test_runtime_services(&req); + let resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + )) + .expect("should route discovery request"); + assert_eq!( + resp.get_status(), + StatusCode::OK, + "should keep explicit built-in routes ahead of asset routes" + ); +} + +#[test] +fn integration_routes_take_precedence_over_asset_routes() { + let mut settings = create_test_settings(); + settings.proxy.asset_routes = vec![ProxyAssetRoute { + prefix: "/prebid.js".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }]; + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let req = Request::get("https://test.com/prebid.js"); + let services = test_runtime_services(&req); + let mut resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + )) + .expect("should route integration request"); + assert_eq!( + resp.get_status(), + StatusCode::OK, + "should keep explicit integration routes ahead of asset routes" + ); + assert_eq!( + resp.take_body_str(), + "// Script overridden by Trusted Server\n", + "should serve the integration response instead of proxying to the asset origin" + ); +} diff --git a/crates/trusted-server-core/src/backend.rs b/crates/trusted-server-core/src/backend.rs index 468a3f83..808bbcfe 100644 --- a/crates/trusted-server-core/src/backend.rs +++ b/crates/trusted-server-core/src/backend.rs @@ -46,6 +46,7 @@ pub struct BackendConfig<'a> { port: Option, certificate_check: bool, first_byte_timeout: Duration, + override_host: Option<&'a str>, } impl<'a> BackendConfig<'a> { @@ -61,6 +62,7 @@ impl<'a> BackendConfig<'a> { port: None, certificate_check: true, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + override_host: None, } } @@ -79,6 +81,14 @@ impl<'a> BackendConfig<'a> { self } + /// Override the Host header sent upstream while keeping the backend target, + /// TLS SNI, and certificate verification tied to [`Self::host`]. + #[must_use] + pub fn override_host(mut self, override_host: Option<&'a str>) -> Self { + self.override_host = override_host; + self + } + /// Set the maximum time to wait for the first byte of the response. /// /// Defaults to 15 seconds. For latency-sensitive paths like auction @@ -109,6 +119,14 @@ impl<'a> BackendConfig<'a> { message: "host contains control characters".to_string(), })); } + if self + .override_host + .is_some_and(|host| host.is_empty() || host.chars().any(char::is_control)) + { + return Err(Report::new(TrustedServerError::Proxy { + message: "override host is empty or contains control characters".to_string(), + })); + } if self.scheme.chars().any(char::is_control) { return Err(Report::new(TrustedServerError::Proxy { message: "scheme contains control characters".to_string(), @@ -125,11 +143,17 @@ impl<'a> BackendConfig<'a> { } else { "_nocert" }; + let override_host_suffix = self + .override_host + .filter(|host| !host.is_empty()) + .map(|host| format!("_oh_{}", host.replace(['.', ':'], "_"))) + .unwrap_or_default(); let timeout_ms = self.first_byte_timeout.as_millis(); let backend_name = format!( - "backend_{}{}_t{}", + "backend_{}{}{}_t{}", name_base.replace(['.', ':'], "_"), cert_suffix, + override_host_suffix, timeout_ms ); @@ -165,11 +189,12 @@ impl<'a> BackendConfig<'a> { let host_with_port = format!("{}:{}", self.host, target_port); - let host_header = compute_host_header(self.scheme, self.host, target_port); + let default_host_header = compute_host_header(self.scheme, self.host, target_port); + let host_header = self.override_host.unwrap_or(&default_host_header); // Target base is host[:port]; SSL is enabled only for https scheme let mut builder = Backend::builder(&backend_name, &host_with_port) - .override_host(&host_header) + .override_host(host_header) .connect_timeout(Duration::from_secs(1)) .first_byte_timeout(self.first_byte_timeout) .between_bytes_timeout(Duration::from_secs(10)); @@ -278,6 +303,26 @@ impl<'a> BackendConfig<'a> { origin_url: &str, certificate_check: bool, first_byte_timeout: Duration, + ) -> Result> { + Self::from_url_with_first_byte_timeout_and_override_host( + origin_url, + certificate_check, + first_byte_timeout, + None, + ) + } + + /// Parse an origin URL and ensure a dynamic backend with an optional upstream Host override. + /// + /// # Errors + /// + /// Returns an error if the URL cannot be parsed or lacks a host, or if + /// backend creation fails. + pub fn from_url_with_first_byte_timeout_and_override_host( + origin_url: &str, + certificate_check: bool, + first_byte_timeout: Duration, + override_host: Option<&str>, ) -> Result> { let (scheme, host, port) = Self::parse_origin(origin_url)?; @@ -285,6 +330,7 @@ impl<'a> BackendConfig<'a> { .port(port) .certificate_check(certificate_check) .first_byte_timeout(first_byte_timeout) + .override_host(override_host) .ensure() } @@ -400,6 +446,31 @@ mod tests { assert_eq!(name, "backend_http_example_org_80_t15000"); } + #[test] + fn override_host_changes_backend_name() { + let (name, _) = BackendConfig::new("https", "backend.example.net") + .override_host(Some("www.example.com")) + .compute_name() + .expect("should compute name with Host override"); + + assert_eq!( + name, "backend_https_backend_example_net_443_oh_www_example_com_t15000", + "should isolate dynamic backends with different Host overrides" + ); + } + + #[test] + fn error_on_override_host_with_control_characters() { + let err = BackendConfig::new("https", "origin.example.com") + .override_host(Some("www.example.com\nINFO fake log entry")) + .predict_name() + .expect_err("should reject override host containing newline"); + assert!( + err.to_string().contains("override host"), + "should report invalid override host in error message" + ); + } + #[test] fn error_on_host_with_control_characters() { let err = BackendConfig::new("https", "evil.com\nINFO fake log entry") diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 63628ad6..ac9b2bda 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -201,7 +201,7 @@ struct StubPendingResponse { /// Test stub for [`PlatformHttpClient`] that records call backend names and /// returns pre-queued canned responses for `send`, `send_async`, and `select`. /// -/// Responses are stored as `(status_code, body_bytes)` to remain [`Send`]. +/// Responses are stored as status/body/header parts to remain [`Send`]. /// [`PlatformResponse`] contains [`edgezero_core::body::Body`] which wraps a /// `LocalBoxStream` that is `!Send`, so it cannot be stored directly in a /// `Mutex` field. @@ -212,12 +212,17 @@ struct StubPendingResponse { /// sites. pub(crate) struct StubHttpClient { calls: Mutex>, - // (status_code, body_bytes) — kept Send by avoiding Body::Stream - responses: Mutex)>>, + responses: Mutex>, // Headers captured per send call, stored as (name, value) string pairs. request_headers: Mutex>>, } +struct StubHttpResponse { + status: u16, + body: Vec, + headers: Vec<(String, String)>, +} + impl StubHttpClient { pub fn new() -> Self { Self { @@ -229,10 +234,28 @@ impl StubHttpClient { /// Queue a canned response by status code and body bytes. pub fn push_response(&self, status: u16, body: Vec) { + self.push_response_with_headers(status, body, Vec::<(String, String)>::new()); + } + + /// Queue a canned response with headers. + pub fn push_response_with_headers( + &self, + status: u16, + body: Vec, + headers: Vec<(impl Into, impl Into)>, + ) { + let headers = headers + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(); self.responses .lock() .expect("should lock responses") - .push_back((status, body)); + .push_back(StubHttpResponse { + status, + body, + headers, + }); } /// Return backend names recorded across all `send` calls, in order. @@ -279,16 +302,19 @@ impl PlatformHttpClient for StubHttpClient { .expect("should lock request_headers") .push(headers); - let (status, body_bytes) = self + let response = self .responses .lock() .expect("should lock responses") .pop_front() .ok_or_else(|| Report::new(PlatformError::HttpClient))?; - let edge_response = edgezero_core::http::response_builder() - .status(status) - .body(edgezero_core::body::Body::from(body_bytes)) + let mut builder = edgezero_core::http::response_builder().status(response.status); + for (name, value) in response.headers { + builder = builder.header(name, value); + } + let edge_response = builder + .body(edgezero_core::body::Body::from(response.body)) .change_context(PlatformError::HttpClient)?; Ok(PlatformResponse::new(edge_response)) @@ -304,7 +330,7 @@ impl PlatformHttpClient for StubHttpClient { .expect("should lock calls") .push(backend_name.clone()); - let (status, body_bytes) = self + let response = self .responses .lock() .expect("should lock responses") @@ -313,8 +339,8 @@ impl PlatformHttpClient for StubHttpClient { let pending = StubPendingResponse { backend_name: backend_name.clone(), - status, - body: body_bytes, + status: response.status, + body: response.body, }; Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index d1da3aa7..dc71b867 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -18,7 +18,7 @@ use crate::error::TrustedServerError; use crate::platform::{ PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, RuntimeServices, }; -use crate::settings::Settings; +use crate::settings::{ProxyAssetRoute, Settings}; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; /// Chunk size used for streaming content through the rewrite pipeline. @@ -39,6 +39,25 @@ const PROXY_FORWARD_HEADERS: [header::HeaderName; 5] = [ HEADER_X_FORWARDED_FOR, ]; +/// Curated request headers preserved for asset proxying. +/// +/// Unlike the HTML publisher fallback, asset requests need cache validation and +/// byte-range semantics to keep 304/206 responses working for browsers. +const ASSET_PROXY_FORWARD_HEADERS: [header::HeaderName; 12] = [ + HEADER_USER_AGENT, + HEADER_ACCEPT, + HEADER_ACCEPT_ENCODING, + HEADER_ACCEPT_LANGUAGE, + HEADER_REFERER, + HEADER_X_FORWARDED_FOR, + header::IF_NONE_MATCH, + header::IF_MODIFIED_SINCE, + header::IF_MATCH, + header::IF_UNMODIFIED_SINCE, + header::RANGE, + header::IF_RANGE, +]; + /// Convert a platform-neutral response into a [`fastly::Response`] for downstream processing. /// /// Shared with `auction/orchestrator.rs`. Both files will migrate off `fastly::Response` @@ -494,6 +513,156 @@ pub async fn proxy_request( .await } +fn default_port_for_scheme(scheme: &str) -> Option { + match scheme { + "http" => Some(80), + "https" => Some(443), + _ => None, + } +} + +fn build_asset_proxy_target_url( + route: &ProxyAssetRoute, + path: &str, + query: &str, +) -> Result> { + let mut target_url = + url::Url::parse(&route.origin_url).change_context(TrustedServerError::Proxy { + message: format!("Invalid asset origin_url: {}", route.origin_url), + })?; + + let scheme = target_url.scheme(); + if scheme != "http" && scheme != "https" { + return Err(Report::new(TrustedServerError::Proxy { + message: format!("Unsupported asset origin_url scheme: {scheme}"), + })); + } + + if target_url.host_str().is_none() { + return Err(Report::new(TrustedServerError::Proxy { + message: "Missing host in asset origin_url".to_string(), + })); + } + + let target_path = route.target_path_for(path)?; + target_url.set_path(&target_path); + if query.is_empty() { + target_url.set_query(None); + } else { + target_url.set_query(Some(query)); + } + + Ok(target_url) +} + +fn asset_origin_host_header( + target_url: &url::Url, +) -> Result> { + let scheme = target_url.scheme(); + let host = target_url.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "Missing host in asset target URL".to_string(), + }) + })?; + let resolved_port = target_url.port_or_known_default().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: format!("Unsupported asset target URL scheme: {scheme}"), + }) + })?; + let host_header = if Some(resolved_port) == default_port_for_scheme(scheme) { + host.to_string() + } else { + format!("{host}:{resolved_port}") + }; + + HeaderValue::from_str(&host_header).change_context(TrustedServerError::InvalidHeaderValue { + message: format!("invalid asset Host header value: {host_header}"), + }) +} + +/// Proxy a configured first-party asset path to its matched asset origin. +/// +/// This is a lean raw pass-through path: it preserves status/body/headers, +/// does not follow redirects, and bypasses publisher-page processing. +/// +/// # Errors +/// +/// Returns an error if the configured origin URL is invalid, backend +/// registration fails, or the upstream request cannot be sent. +pub async fn handle_asset_proxy_request( + settings: &Settings, + services: &RuntimeServices, + req: Request, + route: &ProxyAssetRoute, +) -> Result> { + let target_url = + build_asset_proxy_target_url(route, req.get_path(), req.get_query_str().unwrap_or(""))?; + let scheme = target_url.scheme(); + let host = target_url.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "Missing host in asset target URL".to_string(), + }) + })?; + + let backend_name = services + .backend() + .ensure(&PlatformBackendSpec { + scheme: scheme.to_string(), + host: host.to_string(), + port: target_url.port(), + certificate_check: settings.proxy.certificate_check, + first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + }) + .change_context(TrustedServerError::Proxy { + message: "asset backend registration failed".to_string(), + })?; + + let mut builder = edge_request_builder().method(req.get_method().clone()).uri( + target_url + .as_str() + .parse::() + .change_context(TrustedServerError::Proxy { + message: "invalid asset target URL".to_string(), + })?, + ); + + let mut outbound_headers = http::HeaderMap::new(); + for header_name in ASSET_PROXY_FORWARD_HEADERS { + if let Some(value) = req.get_header(&header_name) { + outbound_headers.insert(header_name, value.clone()); + } + } + outbound_headers.insert(header::HOST, asset_origin_host_header(&target_url)?); + + for (name, value) in &outbound_headers { + builder = builder.header(name, value); + } + + let edge_req = + builder + .body(EdgeBody::from(Vec::new())) + .change_context(TrustedServerError::Proxy { + message: "failed to build asset proxy request".to_string(), + })?; + + let platform_resp = services + .http_client() + .send(PlatformHttpRequest::new(edge_req, backend_name)) + .await + .change_context(TrustedServerError::Proxy { + message: "Failed to proxy asset request".to_string(), + })?; + + let mut response = platform_response_to_fastly(platform_resp)?; + + // Asset origins must not be able to set first-party cookies or publisher + // domain transport security policy through this proxy path. + response.remove_header(header::SET_COOKIE); + response.remove_header(header::STRICT_TRANSPORT_SECURITY); + + Ok(response) +} + /// Upserts the `ts-ec` query parameter on a URL, replacing any existing value. fn upsert_ec_query_param(url: &mut url::Url, ec_id: &str) { let mut pairs: Vec<(String, String)> = url @@ -1250,6 +1419,7 @@ mod tests { use std::sync::Arc; use super::{ + asset_origin_host_header, build_asset_proxy_target_url, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, redirect_is_permitted, ProxyRequestConfig, @@ -1258,11 +1428,14 @@ mod tests { use crate::constants::HEADER_ACCEPT; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; - use crate::platform::test_support::{build_services_with_http_client, noop_services}; + use crate::platform::test_support::{ + build_services_with_http_client, noop_services, StubHttpClient, + }; use crate::platform::{ PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, }; + use crate::settings::ProxyAssetRoute; use crate::test_support::tests::create_test_settings; use bytes::Bytes; use edgezero_core::body::Body as EdgeBody; @@ -2089,6 +2262,265 @@ mod tests { ); } + #[test] + fn build_asset_proxy_target_url_preserves_path_and_query() { + let route = ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }; + let target_url = + build_asset_proxy_target_url(&route, "/.images/foo.jpg", "auto=webp&width=800") + .expect("should build asset target URL"); + + assert_eq!( + target_url.as_str(), + "https://assets.example.com/.images/foo.jpg?auto=webp&width=800", + "should preserve the incoming path and query exactly" + ); + } + + #[test] + fn build_asset_proxy_target_url_applies_cdn_style_rewrite() { + let route = ProxyAssetRoute { + prefix: "/.image/".to_string(), + origin_url: "https://assets-cdn.example.com".to_string(), + path_pattern: Some(r"^/\.image/(.*)/[^/]+\.([^/.]+)$".to_string()), + target_path: Some("/image/upload/$1.$2".to_string()), + }; + let target_url = build_asset_proxy_target_url( + &route, + "/.image/c_fit,w_1440/MjA/example.jpg", + "auto=webp", + ) + .expect("should build rewritten asset target URL"); + + assert_eq!( + target_url.as_str(), + "https://assets-cdn.example.com/image/upload/c_fit,w_1440/MjA.jpg?auto=webp", + "should rewrite the path generically while preserving query parameters" + ); + } + + #[test] + fn build_asset_proxy_target_url_applies_static_prefix_rewrite() { + let route = ProxyAssetRoute { + prefix: "/_next/static/".to_string(), + origin_url: "https://static-assets.example.com".to_string(), + path_pattern: Some(r"^(.*)$".to_string()), + target_path: Some("/_network$1".to_string()), + }; + let target_url = build_asset_proxy_target_url(&route, "/_next/static/chunks/app.js", "") + .expect("should build rewritten static asset target URL"); + + assert_eq!( + target_url.as_str(), + "https://static-assets.example.com/_network/_next/static/chunks/app.js", + "should prepend the configured upstream path prefix" + ); + } + + #[test] + fn build_asset_proxy_target_url_errors_when_rewrite_pattern_misses() { + let route = ProxyAssetRoute { + prefix: "/.image/".to_string(), + origin_url: "https://assets.example.com".to_string(), + path_pattern: Some(r"^/\.image/(.*)\.jpg$".to_string()), + target_path: Some("/image/upload/$1.jpg".to_string()), + }; + let err = build_asset_proxy_target_url(&route, "/.image/foo.png", "") + .expect_err("should reject paths that do not match the configured rewrite"); + + assert!( + format!("{err:?}").contains("did not match path_pattern"), + "should explain the rewrite miss: {err:?}" + ); + } + + #[test] + fn build_asset_proxy_target_url_errors_when_rewrite_omits_leading_slash() { + let route = ProxyAssetRoute { + prefix: "/assets/".to_string(), + origin_url: "https://assets.example.com".to_string(), + path_pattern: Some(r"^/assets/(.*)$".to_string()), + target_path: Some("$1".to_string()), + }; + let err = build_asset_proxy_target_url(&route, "/assets/app.js", "") + .expect_err("should reject rewritten paths without a leading slash"); + + assert!( + format!("{err:?}").contains("must start with '/'"), + "should explain the invalid rewritten path: {err:?}" + ); + } + + #[test] + fn asset_origin_host_header_omits_standard_port() { + let target_url = url::Url::parse("https://assets.example.com/.images/foo.jpg") + .expect("should parse URL"); + let host = asset_origin_host_header(&target_url).expect("should compute Host header"); + assert_eq!( + host.to_str().expect("should serialize Host header"), + "assets.example.com", + "should omit standard HTTPS port from Host header" + ); + } + + #[test] + fn asset_origin_host_header_includes_non_standard_port() { + let target_url = url::Url::parse("https://assets.example.com:8443/.images/foo.jpg") + .expect("should parse URL"); + let host = asset_origin_host_header(&target_url).expect("should compute Host header"); + assert_eq!( + host.to_str().expect("should serialize Host header"), + "assets.example.com:8443", + "should include non-standard port in Host header" + ); + } + + #[tokio::test] + async fn handle_asset_proxy_request_forwards_asset_headers_and_host() { + use crate::platform::test_support::StubHttpClient; + + 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 settings = create_test_settings(); + let mut req = Request::new( + Method::GET, + "https://www.example.com/.images/foo.jpg?auto=webp", + ); + req.set_header(header::USER_AGENT, "asset-agent/1.0"); + req.set_header(header::ACCEPT, "image/avif,image/webp,image/*,*/*;q=0.8"); + req.set_header(header::ACCEPT_ENCODING, "gzip, br"); + req.set_header(header::ACCEPT_LANGUAGE, "en-US"); + req.set_header(header::REFERER, "https://www.example.com/article"); + req.set_header(header::IF_NONE_MATCH, "\"asset-etag\""); + req.set_header(header::IF_MODIFIED_SINCE, "Thu, 13 Mar 2025 08:00:00 GMT"); + req.set_header(header::IF_MATCH, "\"asset-precondition\""); + req.set_header(header::IF_UNMODIFIED_SINCE, "Thu, 13 Mar 2025 09:00:00 GMT"); + req.set_header(header::RANGE, "bytes=0-1023"); + req.set_header(header::IF_RANGE, "\"asset-range\""); + req.set_header(header::HeaderName::from_static("x-custom-test"), "drop-me"); + + let route = ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com:8443".to_string(), + ..Default::default() + }; + let response = handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy asset request"); + assert_eq!(response.get_status(), StatusCode::OK); + + let all_headers = stub.recorded_request_headers(); + assert_eq!(all_headers.len(), 1, "should have captured one request"); + let sent = &all_headers[0]; + let header_value = |name: &str| -> Option { + sent.iter().find(|(n, _)| n == name).map(|(_, v)| v.clone()) + }; + + assert_eq!( + header_value("user-agent").as_deref(), + Some("asset-agent/1.0"), + "should forward User-Agent" + ); + assert_eq!( + header_value("accept-encoding").as_deref(), + Some("gzip, br"), + "should preserve the incoming Accept-Encoding" + ); + assert_eq!( + header_value("if-none-match").as_deref(), + Some("\"asset-etag\""), + "should forward conditional ETag validation headers" + ); + assert_eq!( + header_value("if-modified-since").as_deref(), + Some("Thu, 13 Mar 2025 08:00:00 GMT"), + "should forward conditional date validation headers" + ); + assert_eq!( + header_value("if-match").as_deref(), + Some("\"asset-precondition\""), + "should forward precondition headers" + ); + assert_eq!( + header_value("if-unmodified-since").as_deref(), + Some("Thu, 13 Mar 2025 09:00:00 GMT"), + "should forward date precondition headers" + ); + assert_eq!( + header_value("range").as_deref(), + Some("bytes=0-1023"), + "should forward byte-range requests" + ); + assert_eq!( + header_value("if-range").as_deref(), + Some("\"asset-range\""), + "should forward range validators" + ); + assert_eq!( + header_value("host").as_deref(), + Some("assets.example.com:8443"), + "should override Host to the asset origin host" + ); + assert!( + header_value("x-custom-test").is_none(), + "should not forward unrelated custom headers" + ); + } + + #[tokio::test] + async fn handle_asset_proxy_request_strips_unsafe_response_headers() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + Vec::new(), + vec![ + (header::SET_COOKIE.as_str(), "asset=1; Path=/; Secure"), + (header::SET_COOKIE.as_str(), "other=2; Path=/; Secure"), + ( + header::STRICT_TRANSPORT_SECURITY.as_str(), + "max-age=31536000; includeSubDomains; preload", + ), + (header::ETAG.as_str(), "\"asset-etag\""), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let req = Request::new(Method::GET, "https://www.example.com/.images/foo.jpg"); + + let route = ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + ..Default::default() + }; + let response = handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy asset request"); + + assert!( + response.get_header(header::SET_COOKIE).is_none(), + "should strip upstream Set-Cookie headers from asset responses" + ); + assert!( + response + .get_header(header::STRICT_TRANSPORT_SECURITY) + .is_none(), + "should strip upstream HSTS headers from asset responses" + ); + assert_eq!( + response.get_header_str(header::ETAG), + Some("\"asset-etag\""), + "should preserve safe cache validator headers on asset responses" + ); + } + #[tokio::test] async fn proxy_request_returns_error_for_streaming_platform_response_body() { let services = build_services_with_http_client( diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c181e049..3caf136d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -516,20 +516,24 @@ pub fn handle_publisher_request( let ec_allowed = allows_ec_creation(&consent_context); log::debug!("Proxy ec_allowed: {}", ec_allowed); - let backend_name = BackendConfig::from_url( + let origin_host_header = settings.publisher.origin_host_header_value(); + let origin_rewrite_url = settings.publisher.origin_rewrite_url(); + let backend_name = BackendConfig::from_url_with_first_byte_timeout_and_override_host( &settings.publisher.origin_url, settings.proxy.certificate_check, + crate::backend::DEFAULT_FIRST_BYTE_TIMEOUT, + Some(&origin_host_header), )?; - let origin_host = settings.publisher.origin_host(); log::debug!( - "Proxying to dynamic backend: {} (from {})", + "Proxying to dynamic backend: {} (from {}, Host: {})", backend_name, - settings.publisher.origin_url + settings.publisher.origin_url, + origin_host_header ); // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); - req.set_header("host", &origin_host); + req.set_header("host", &origin_host_header); let mut response = req .send(&backend_name) @@ -614,7 +618,7 @@ pub fn handle_publisher_request( ResponseRoute::Stream => { log::debug!( "Streaming response - Content-Type: {}, Content-Encoding: {}, Request Host: {}, Origin Host: {}", - content_type, content_encoding, request_host, origin_host + content_type, content_encoding, request_host, origin_host_header ); let body = response.take_body(); @@ -625,8 +629,8 @@ pub fn handle_publisher_request( body, params: OwnedProcessResponseParams { content_encoding, - origin_host, - origin_url: settings.publisher.origin_url.clone(), + origin_host: origin_host_header, + origin_url: origin_rewrite_url, request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, @@ -636,14 +640,14 @@ pub fn handle_publisher_request( ResponseRoute::BufferedProcessed => { log::debug!( "Buffered response - Content-Type: {}, Content-Encoding: {}, Request Host: {}, Origin Host: {}", - content_type, content_encoding, request_host, origin_host + content_type, content_encoding, request_host, origin_host_header ); let body = response.take_body(); let params = ProcessResponseParams { content_encoding: &content_encoding, - origin_host: &origin_host, - origin_url: &settings.publisher.origin_url, + origin_host: &origin_host_header, + origin_url: &origin_rewrite_url, request_host, request_scheme, settings, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index d851aff7..ea727d00 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3,7 +3,7 @@ use error_stack::{Report, ResultExt}; use regex::Regex; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use serde_json::Value as JsonValue; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::sync::OnceLock; use url::Url; @@ -24,6 +24,9 @@ pub struct Publisher { pub cookie_domain: String, #[validate(custom(function = validate_no_trailing_slash))] pub origin_url: String, + /// Optional upstream Host header value used when connecting to an origin + /// whose routing host differs from the backend host. + pub origin_host_header: Option, /// Secret used to encrypt/decrypt proxied URLs in `/first-party/proxy`. /// Keep this secret stable to allow existing links to decode. #[validate(custom(function = validate_redacted_not_empty))] @@ -43,6 +46,61 @@ impl Publisher { .any(|p| p.eq_ignore_ascii_case(proxy_secret)) } + fn normalize(&mut self) { + self.origin_host_header = self + .origin_host_header + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + + /// Eagerly validate runtime-only publisher configuration. + /// + /// # Errors + /// + /// Returns a configuration error if the configured origin Host header is invalid. + pub fn prepare_runtime(&self) -> Result<(), Report> { + if let Some(host_header) = &self.origin_host_header { + validate_host_header_value(host_header).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "publisher.origin_host_header `{host_header}` is invalid: {err}" + ), + }) + })?; + } + + Ok(()) + } + + /// Returns the upstream Host header to send to the publisher origin. + #[must_use] + pub fn origin_host_header_value(&self) -> String { + self.origin_host_header + .clone() + .unwrap_or_else(|| self.origin_host()) + } + + /// Returns the public origin URL whose URLs should be rewritten to the request host. + /// + /// When `origin_host_header` is configured, the backend connection target + /// (`origin_url`) may be an internal routing host while page content still + /// references the public origin host. In that case, rewrite against the + /// configured Host header using the origin URL's scheme. + #[must_use] + pub fn origin_rewrite_url(&self) -> String { + let Some(host_header) = self.origin_host_header.as_deref() else { + return self.origin_url.clone(); + }; + + let scheme = Url::parse(&self.origin_url) + .ok() + .map(|url| url.scheme().to_string()) + .unwrap_or_else(|| "https".to_string()); + + format!("{scheme}://{host_header}") + } + /// Extracts the host (including port if present) from the `origin_url`. /// /// # Examples @@ -54,6 +112,7 @@ impl Publisher { /// domain: "example.com".to_string(), /// cookie_domain: ".example.com".to_string(), /// origin_url: "https://origin.example.com:8080".to_string(), + /// origin_host_header: None, /// proxy_secret: Redacted::new("proxy-secret".to_string()), /// }; /// assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -333,6 +392,150 @@ fn default_request_signing_enabled() -> bool { false } +/// A path-prefix asset route that proxies matched first-party requests to an alternate origin. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct ProxyAssetRoute { + /// Path prefix matched against the incoming request path. Must start with `/`. + /// + /// Matching uses string-prefix semantics, not path-segment semantics. Include + /// a trailing `/` unless you intentionally want `/static` to match paths such + /// as `/staticfile.js`. + pub prefix: String, + /// Absolute `http` or `https` origin used for upstream requests. + /// + /// Only the scheme, host, and port are used. Any path or query configured on + /// this URL is rejected because the incoming request path/query, or the + /// configured rewrite result, replaces them at runtime. + pub origin_url: String, + /// Optional regex matched against the incoming request path before proxying. + pub path_pattern: Option, + /// Optional regex replacement used with [`Self::path_pattern`] to build the upstream path. + /// + /// Must be configured together with [`Self::path_pattern`] and must produce a + /// path that starts with `/`. + pub target_path: Option, +} + +impl ProxyAssetRoute { + fn normalize(&mut self) { + self.prefix = self.prefix.trim().to_string(); + self.origin_url = self.origin_url.trim().to_string(); + self.path_pattern = self + .path_pattern + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.target_path = self + .target_path + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + + fn compiled_path_pattern(&self) -> Result, Report> { + let Some(pattern) = self.path_pattern.as_deref() else { + return Ok(None); + }; + + Regex::new(pattern).map(Some).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes path_pattern `{pattern}` failed to compile: {err}" + ), + }) + }) + } + + /// Rewrite a matched request path to the configured upstream target path. + /// + /// # Errors + /// + /// Returns a proxy/configuration error if the rewrite is incomplete, does not + /// match the request path, or produces a path that does not start with `/`. + pub fn target_path_for(&self, path: &str) -> Result> { + match (&self.path_pattern, &self.target_path) { + (None, None) => Ok(path.to_string()), + (Some(_), Some(target_path)) => { + let regex = self.compiled_path_pattern()?.ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes prefix `{}` has a target_path without path_pattern", + self.prefix + ), + }) + })?; + + if !regex.is_match(path) { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "asset path `{path}` matched prefix `{}` but did not match path_pattern", + self.prefix + ), + })); + } + + let rewritten = regex.replace(path, target_path.as_str()).into_owned(); + if !rewritten.starts_with('/') { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes prefix `{}` rewrote `{path}` to `{rewritten}`, which must start with '/'", + self.prefix + ), + })); + } + + Ok(rewritten) + } + _ => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes prefix `{}` must configure path_pattern and target_path together", + self.prefix + ), + })), + } + } + + /// Eagerly validate runtime-only asset-route configuration. + /// + /// # Errors + /// + /// Returns a configuration error if the asset-route prefix, origin URL, or + /// path rewrite settings are invalid. + pub fn prepare_runtime(&self) -> Result<(), Report> { + validate_asset_route_prefix(&self.prefix).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes prefix `{}` is invalid: {err}", + self.prefix + ), + }) + })?; + + validate_proxy_origin_url(&self.origin_url).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes origin_url `{}` is invalid: {err}", + self.origin_url + ), + }) + })?; + + match (&self.path_pattern, &self.target_path) { + (None, None) | (Some(_), Some(_)) => {} + _ => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes prefix `{}` must configure path_pattern and target_path together", + self.prefix + ), + })); + } + } + + self.compiled_path_pattern().map(|_| ()) + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Proxy { /// Enable TLS certificate verification when proxying to HTTPS origins. @@ -351,6 +554,9 @@ pub struct Proxy { /// initiated by signed first-party proxy URLs. #[serde(default, deserialize_with = "vec_from_seq_or_map")] pub allowed_domains: Vec, + /// Path-prefix-based asset proxy routes evaluated before publisher fallback. + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub asset_routes: Vec, } fn default_certificate_check() -> bool { @@ -362,6 +568,7 @@ impl Default for Proxy { Self { certificate_check: default_certificate_check(), allowed_domains: Vec::new(), + asset_routes: Vec::new(), } } } @@ -396,6 +603,55 @@ impl Proxy { "proxy.allowed_domains is empty: all redirect destinations are permitted (open mode)" ); } + + for route in &mut self.asset_routes { + route.normalize(); + } + + let mut seen_prefixes = HashSet::new(); + for route in &self.asset_routes { + if !route.prefix.is_empty() && !seen_prefixes.insert(route.prefix.clone()) { + log::warn!( + "proxy.asset_routes contains duplicate prefix `{}`; the first configured route will be used", + route.prefix + ); + } + } + } + + /// Eagerly validate runtime-only proxy settings artifacts. + /// + /// Asset-route validation lives here so regex compilation and origin URL + /// semantic checks fail fast alongside other runtime-prepared settings. + /// + /// # Errors + /// + /// Returns a configuration error if any configured asset route is invalid. + pub fn prepare_runtime(&self) -> Result<(), Report> { + for route in &self.asset_routes { + route.prepare_runtime()?; + } + + Ok(()) + } + + /// Resolve the longest matching asset route for the given request path. + #[must_use] + pub fn asset_route_for_path(&self, path: &str) -> Option<&ProxyAssetRoute> { + let mut best_match: Option<&ProxyAssetRoute> = None; + + for route in &self.asset_routes { + if !path.starts_with(&route.prefix) { + continue; + } + + match best_match { + Some(current) if current.prefix.len() >= route.prefix.len() => {} + _ => best_match = Some(route), + } + } + + best_match } } @@ -455,6 +711,7 @@ impl Settings { message: "Failed to deserialize TOML configuration".to_string(), })?; + settings.publisher.normalize(); settings.proxy.normalize(); settings.consent.validate(); settings.prepare_runtime()?; @@ -493,6 +750,7 @@ impl Settings { })?; settings.integrations.normalize(); + settings.publisher.normalize(); settings.proxy.normalize(); settings.consent.validate(); @@ -514,6 +772,9 @@ impl Settings { /// /// Returns a configuration error if any cached runtime artifact cannot be prepared. pub fn prepare_runtime(&self) -> Result<(), Report> { + self.publisher.prepare_runtime()?; + self.proxy.prepare_runtime()?; + for handler in &self.handlers { handler.prepare_runtime()?; } @@ -521,6 +782,12 @@ impl Settings { Ok(()) } + /// Resolve the longest matching asset route for the request path. + #[must_use] + pub fn asset_route_for_path(&self, path: &str) -> Option<&ProxyAssetRoute> { + self.proxy.asset_route_for_path(path) + } + /// Resolve the first handler whose regex matches the request path. /// /// # Errors @@ -629,7 +896,7 @@ fn validate_no_trailing_slash(value: &str) -> Result<(), ValidationError> { if value.ends_with('/') { let mut err = ValidationError::new("trailing_slash"); err.add_param("value".into(), &value); - err.message = Some("origin_url must not end with '/'".into()); + err.message = Some("origin_url must not include a trailing slash".into()); return Err(err); } Ok(()) @@ -642,6 +909,72 @@ fn validate_redacted_not_empty(value: &Redacted) -> Result<(), Validatio Ok(()) } +fn validate_host_header_value(value: &str) -> Result<(), ValidationError> { + if value.is_empty() || value.contains(['\0', '\n', '\r']) { + let mut err = ValidationError::new("invalid_host_header"); + err.add_param("value".into(), &value); + err.message = + Some("host header must be non-empty and must not contain control characters".into()); + return Err(err); + } + + Ok(()) +} + +fn validate_asset_route_prefix(value: &str) -> Result<(), ValidationError> { + if !value.starts_with('/') { + let mut err = ValidationError::new("invalid_prefix"); + err.add_param("value".into(), &value); + err.message = Some("asset-route prefix must start with '/'".into()); + return Err(err); + } + + Ok(()) +} + +fn validate_proxy_origin_url(value: &str) -> Result<(), ValidationError> { + validate_no_trailing_slash(value)?; + + let parsed = Url::parse(value).map_err(|parse_error| { + let mut err = ValidationError::new("invalid_origin_url"); + err.add_param("value".into(), &value); + err.add_param("message".into(), &parse_error.to_string()); + err.message = Some("origin_url must be an absolute http or https URL".into()); + err + })?; + + if !matches!(parsed.scheme(), "http" | "https") { + let mut err = ValidationError::new("invalid_origin_url_scheme"); + err.add_param("value".into(), &value); + err.message = Some("origin_url must use http or https".into()); + return Err(err); + } + + if parsed.host_str().is_none() { + let mut err = ValidationError::new("missing_origin_host"); + err.add_param("value".into(), &value); + err.message = Some("origin_url must include a host".into()); + return Err(err); + } + + if !matches!(parsed.path(), "" | "/") { + let mut err = ValidationError::new("origin_url_has_path"); + err.add_param("value".into(), &value); + err.message = + Some("origin_url must not include a path; only scheme/host/port are used".into()); + return Err(err); + } + + if parsed.query().is_some() { + let mut err = ValidationError::new("origin_url_has_query"); + err.add_param("value".into(), &value); + err.message = Some("origin_url must not include a query string".into()); + return Err(err); + } + + Ok(()) +} + fn validate_path(value: &str) -> Result<(), ValidationError> { Regex::new(value).map(|_| ()).map_err(|err| { let mut validation_error = ValidationError::new("invalid_regex"); @@ -1347,6 +1680,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "https://origin.example.com:8080".to_string(), + origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -1356,6 +1690,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "https://origin.example.com".to_string(), + origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "origin.example.com"); @@ -1365,6 +1700,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://localhost:9090".to_string(), + origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -1374,6 +1710,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "localhost:9090".to_string(), + origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -1383,6 +1720,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://192.168.1.1:8080".to_string(), + origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "192.168.1.1:8080"); @@ -1392,11 +1730,82 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://[::1]:8080".to_string(), + origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "[::1]:8080"); } + #[test] + fn publisher_origin_host_header_defaults_to_origin_host() { + let publisher = Publisher { + domain: "example.com".to_string(), + cookie_domain: ".example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + origin_host_header: None, + proxy_secret: Redacted::new("test-secret".to_string()), + }; + + assert_eq!( + publisher.origin_host_header_value(), + "origin.example.com", + "should preserve existing Host header behavior by default" + ); + } + + #[test] + fn publisher_origin_host_header_uses_configured_value() { + let mut publisher = Publisher { + domain: "example.com".to_string(), + cookie_domain: ".example.com".to_string(), + origin_url: "https://backend.example.net".to_string(), + origin_host_header: Some(" example.com ".to_string()), + proxy_secret: Redacted::new("test-secret".to_string()), + }; + publisher.normalize(); + + assert_eq!( + publisher.origin_host_header_value(), + "example.com", + "should use the normalized configured upstream Host header" + ); + } + + #[test] + fn publisher_origin_rewrite_url_uses_configured_host_with_origin_scheme() { + let mut publisher = Publisher { + domain: "example.com".to_string(), + cookie_domain: ".example.com".to_string(), + origin_url: "https://backend.example.net".to_string(), + origin_host_header: Some("www.example.com".to_string()), + proxy_secret: Redacted::new("test-secret".to_string()), + }; + publisher.normalize(); + + assert_eq!( + publisher.origin_rewrite_url(), + "https://www.example.com", + "should rewrite public-origin URLs instead of backend routing host URLs" + ); + } + + #[test] + fn publisher_origin_rewrite_url_defaults_to_origin_url_without_host_override() { + let publisher = Publisher { + domain: "example.com".to_string(), + cookie_domain: ".example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + origin_host_header: None, + proxy_secret: Redacted::new("test-secret".to_string()), + }; + + assert_eq!( + publisher.origin_rewrite_url(), + "https://origin.example.com", + "should preserve existing rewrite behavior without a Host override" + ); + } + #[test] fn test_integration_settings_from_env() { use crate::integrations::testlight::TestlightConfig; @@ -1776,6 +2185,7 @@ mod tests { " AD.EXAMPLE.COM ".to_string(), "*.Example.Org".to_string(), ], + asset_routes: vec![], }; proxy.normalize(); assert_eq!( @@ -1795,6 +2205,7 @@ mod tests { "".to_string(), "cdn.example.com".to_string(), ], + asset_routes: vec![], }; proxy.normalize(); assert_eq!( @@ -1809,6 +2220,7 @@ mod tests { let mut proxy = Proxy { certificate_check: true, allowed_domains: vec!["*".to_string(), "tracker.com".to_string()], + asset_routes: vec![], }; proxy.normalize(); assert_eq!( @@ -1823,6 +2235,7 @@ mod tests { let mut proxy = Proxy { certificate_check: true, allowed_domains: vec!["*".to_string()], + asset_routes: vec![], }; proxy.normalize(); assert!( @@ -1836,6 +2249,7 @@ mod tests { let mut proxy = Proxy { certificate_check: true, allowed_domains: vec![" ".to_string(), "\t".to_string()], + asset_routes: vec![], }; proxy.normalize(); assert!( @@ -1844,6 +2258,180 @@ mod tests { ); } + #[test] + fn proxy_normalize_trims_asset_routes() { + let mut proxy = Proxy { + certificate_check: true, + allowed_domains: vec![], + asset_routes: vec![ProxyAssetRoute { + prefix: " /.images/ ".to_string(), + origin_url: " https://assets.example.com ".to_string(), + ..Default::default() + }], + }; + proxy.normalize(); + assert_eq!( + proxy.asset_routes[0].prefix, "/.images/", + "should trim asset-route prefix" + ); + assert_eq!( + proxy.asset_routes[0].origin_url, "https://assets.example.com", + "should trim asset-route origin_url" + ); + } + + #[test] + fn proxy_normalize_trims_asset_route_rewrite_fields() { + let mut proxy = Proxy { + certificate_check: true, + allowed_domains: vec![], + asset_routes: vec![ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://assets.example.com".to_string(), + path_pattern: Some(" ^/(.*)$ ".to_string()), + target_path: Some(" /rewritten/$1 ".to_string()), + }], + }; + proxy.normalize(); + + assert_eq!( + proxy.asset_routes[0].path_pattern.as_deref(), + Some("^/(.*)$"), + "should trim asset-route path_pattern" + ); + assert_eq!( + proxy.asset_routes[0].target_path.as_deref(), + Some("/rewritten/$1"), + "should trim asset-route target_path" + ); + } + + #[test] + fn proxy_asset_route_rewrite_fields_parse_from_toml() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + + [[proxy.asset_routes]] + prefix = "/.image/" + origin_url = "https://assets.example.com" + path_pattern = "^/\\.image/(.*)/[^/]+\\.([^/.]+)$" + target_path = "/image/upload/$1.$2" + "#; + let settings = Settings::from_toml(&toml_str).expect("should parse asset route rewrite"); + let route = settings + .asset_route_for_path("/.image/options/id/example.jpg") + .expect("should match configured asset route"); + + assert_eq!( + route.path_pattern.as_deref(), + Some(r"^/\.image/(.*)/[^/]+\.([^/.]+)$"), + "should preserve the configured rewrite pattern" + ); + assert_eq!( + route.target_path.as_deref(), + Some("/image/upload/$1.$2"), + "should preserve the configured replacement" + ); + } + + #[test] + fn proxy_asset_route_validation_rejects_incomplete_rewrite() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + + [[proxy.asset_routes]] + prefix = "/.image/" + origin_url = "https://assets.example.com" + path_pattern = "^/\\.image/(.*)$" + "#; + let err = Settings::from_toml(&toml_str) + .expect_err("should reject incomplete asset route rewrite"); + + assert!( + format!("{err:?}").contains("must configure path_pattern and target_path together"), + "should mention the incomplete rewrite configuration: {err:?}" + ); + } + + #[test] + fn proxy_asset_route_validation_rejects_invalid_path_pattern() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + + [[proxy.asset_routes]] + prefix = "/.image/" + origin_url = "https://assets.example.com" + path_pattern = "[" + target_path = "/image/upload/$1" + "#; + let err = Settings::from_toml(&toml_str) + .expect_err("should reject invalid asset route path_pattern"); + + assert!( + format!("{err:?}").contains("failed to compile"), + "should mention the invalid regex: {err:?}" + ); + } + + #[test] + fn proxy_asset_route_for_path_prefers_longest_prefix() { + let proxy = Proxy { + certificate_check: true, + allowed_domains: vec![], + asset_routes: vec![ + ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://a.example.com".to_string(), + ..Default::default() + }, + ProxyAssetRoute { + prefix: "/.images/special/".to_string(), + origin_url: "https://b.example.com".to_string(), + ..Default::default() + }, + ], + }; + + let route = proxy + .asset_route_for_path("/.images/special/banner.png") + .expect("should match a configured asset route"); + assert_eq!( + route.origin_url, "https://b.example.com", + "should prefer the most specific prefix" + ); + } + + #[test] + fn proxy_asset_route_for_path_keeps_first_duplicate_prefix() { + let proxy = Proxy { + certificate_check: true, + allowed_domains: vec![], + asset_routes: vec![ + ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://first.example.com".to_string(), + ..Default::default() + }, + ProxyAssetRoute { + prefix: "/.images/".to_string(), + origin_url: "https://second.example.com".to_string(), + ..Default::default() + }, + ], + }; + + let route = proxy + .asset_route_for_path("/.images/banner.png") + .expect("should match duplicate prefixes deterministically"); + assert_eq!( + route.origin_url, "https://first.example.com", + "should keep the first configured duplicate prefix" + ); + } + #[test] fn proxy_normalize_applied_by_from_toml() { let toml_str = crate_test_settings_str() @@ -1862,6 +2450,96 @@ mod tests { ); } + #[test] + fn proxy_asset_route_validation_rejects_prefix_without_leading_slash() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + + [[proxy.asset_routes]] + prefix = ".images/" + origin_url = "https://assets.example.com" + "#; + let err = + Settings::from_toml(&toml_str).expect_err("should reject invalid asset-route prefix"); + assert!( + format!("{err:?}").contains("asset-route prefix must start with '/'"), + "should mention the prefix validation failure: {err:?}" + ); + } + + #[test] + fn proxy_asset_route_validation_rejects_non_http_origin_url() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + + [[proxy.asset_routes]] + prefix = "/.images/" + origin_url = "ftp://assets.example.com" + "#; + let err = Settings::from_toml(&toml_str) + .expect_err("should reject non-http asset-route origin_url"); + assert!( + format!("{err:?}").contains("origin_url must use http or https"), + "should mention the origin_url validation failure: {err:?}" + ); + } + + #[test] + fn proxy_asset_route_validation_rejects_origin_url_path() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + + [[proxy.asset_routes]] + prefix = "/.images/" + origin_url = "https://assets.example.com/api" + "#; + let err = Settings::from_toml(&toml_str) + .expect_err("should reject asset-route origin_url with path"); + assert!( + format!("{err:?}").contains("origin_url must not include a path"), + "should mention the origin_url path validation failure: {err:?}" + ); + } + + #[test] + fn proxy_asset_route_validation_rejects_origin_url_query() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + + [[proxy.asset_routes]] + prefix = "/.images/" + origin_url = "https://assets.example.com?token=abc" + "#; + let err = Settings::from_toml(&toml_str) + .expect_err("should reject asset-route origin_url with query"); + assert!( + format!("{err:?}").contains("origin_url must not include a query string"), + "should mention the origin_url query validation failure: {err:?}" + ); + } + + #[test] + fn proxy_asset_route_validation_accepts_origin_url_host_and_port() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + + [[proxy.asset_routes]] + prefix = "/.images/" + origin_url = "https://assets.example.com:8443" + "#; + let settings = + Settings::from_toml(&toml_str).expect("should accept asset-route origin host and port"); + assert_eq!( + settings.proxy.asset_routes[0].origin_url, "https://assets.example.com:8443", + "should preserve valid origin URL with non-standard port" + ); + } + #[test] fn proxy_normalize_applied_by_from_toml_and_env() { let toml_str = crate_test_settings_str() diff --git a/docs/superpowers/specs/2026-04-28-multi-backend-asset-proxy-design.md b/docs/superpowers/specs/2026-04-28-multi-backend-asset-proxy-design.md new file mode 100644 index 00000000..730360d2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-multi-backend-asset-proxy-design.md @@ -0,0 +1,660 @@ +# Multi-backend Asset Proxy Design + +> Proposed design for path-based first-party asset proxy routing. +> Date: 2026-04-28. + +--- + +## Goal + +Allow Trusted Server to proxy selected first-party asset paths to a different +backend origin than `publisher.origin_url`. + +Example: + +- incoming URL: `https://www.example.com/.images/foo.jpg?w=1200` +- matched rule: `prefix = "/.images/"` +- asset origin: `https://some.fastly-service.com` +- upstream URL: `https://some.fastly-service.com/.images/foo.jpg?w=1200` + +This should happen transparently for normal inbound requests, without requiring +`/first-party/proxy` signed URLs. + +--- + +## Problem + +Today, unknown routes fall through to the publisher proxy path and always go to +one backend: + +- `settings.publisher.origin_url` + +That works for HTML and general publisher-origin traffic, but it does not allow +specific first-party asset namespaces to be served by a separate backend such +as an image CDN, Fastly service, or dedicated asset origin. + +Publishers need to keep asset URLs on their first-party domain while routing +certain path prefixes to a different backend. + +--- + +## Scope + +### In scope + +- Path-prefix-based routing for first-party asset requests +- Multiple configured asset-route rules +- Per-rule alternate `origin_url` +- Transparent proxying for ordinary inbound `GET`/`HEAD` requests +- Preservation of the incoming path and query string +- Raw response pass-through from the matched asset origin +- Deterministic longest-prefix route selection +- Request routing that happens after built-in and integration routes, but + before publisher-origin fallback + +### Out of scope + +- Regex-based route matching +- Path rewrite / prefix replacement +- Cookie, consent, HTML, CSS, or JS rewriting on asset-route responses +- Redirect following for asset routes +- Special cache policy overrides +- Non-`GET` / non-`HEAD` methods +- Per-route header customization +- Health checks, fallback chains, or origin failover + +--- + +## Product Requirements + +### 1. Transparent inbound routing + +The feature applies to normal inbound requests handled by Trusted Server. + +It is **not** an extension of `/first-party/proxy` and does **not** require URL +signing. + +If an incoming request path matches a configured asset route, Trusted Server +proxies it directly to that route's configured origin. + +### 2. Match on simple path prefixes + +Routes are configured as simple prefixes, not regexes. + +Examples: + +- valid: `/.images/` +- valid: `/static/` +- invalid: `.images/` +- invalid: `images/` + +Rule matching is performed against the request path only. Query strings are +ignored for matching. + +### 3. Preserve path and query exactly + +When a rule matches, Trusted Server replaces only the upstream origin +(scheme/host/port) and preserves the rest of the request URL exactly. + +Example: + +- inbound: `/.images/foo/bar.jpg?auto=webp&width=1200` +- upstream path/query: `/.images/foo/bar.jpg?auto=webp&width=1200` + +There is no path rewrite in v1. + +### 4. Multiple rules supported + +Configuration supports multiple asset-route entries. + +Example use cases: + +- `/.images/` → image CDN +- `/static/assets/` → static asset backend +- `/_next/image/` → specialized image transformer + +### 5. Longest matching prefix wins + +If multiple routes match a path, the most specific route wins. + +Example: + +- `/.images/` → backend A +- `/.images/special/` → backend B +- request `/.images/special/x.jpg` → backend B + +### 6. Only `GET` and `HEAD` + +Asset-route matching only applies to `GET` and `HEAD` requests. + +All other methods continue through existing route handling and publisher +fallback behavior unchanged. + +### 7. Explicit routes win first + +Built-in Trusted Server routes and registered integration routes must retain +higher precedence than asset-route matching. + +Asset routes act only inside the fallback proxy space. They must not shadow: + +- `/auction` +- `/first-party/*` +- `/.well-known/*` +- admin routes +- registered integration routes + +### 8. Raw pass-through behavior + +Matched asset routes bypass the publisher-page processing pipeline. + +Specifically, asset-route handling does **not** perform: + +- EC generation / consent pipeline work +- cookie mutation +- HTML rewriting +- CSS rewriting +- URL rewriting +- RSC processing +- post-processing +- redirect following + +The route behaves as a lean transport proxy. + +### 9. Upstream errors are not masked + +If the matched asset origin returns a response, that response is returned to the +client as-is. + +If the asset origin cannot be reached or backend setup fails, Trusted Server +returns the existing error behavior for that failure class. + +It must **not** silently fall back to `publisher.origin_url`. + +### 10. Preserve upstream cache semantics + +Trusted Server passes through upstream cache headers unchanged, including: + +- `Cache-Control` +- `ETag` +- `Last-Modified` +- `Expires` +- `Vary` + +There is no v1 cache override layer. + +### 11. Preserve redirect semantics + +If the asset origin returns a redirect (`301`, `302`, `303`, `307`, `308`), +Trusted Server returns that redirect to the client as-is. + +It does not follow redirects server-side. + +### 12. Preserve `HEAD` semantics + +A `HEAD` request to a matched asset route is proxied upstream as `HEAD` and +returned without body synthesis. + +--- + +## Configuration Design + +Asset routes live under `[proxy]` in `trusted-server.toml`. + +### Proposed shape + +```toml +[proxy] +certificate_check = true + +[[proxy.asset_routes]] +prefix = "/.images/" +origin_url = "https://some.fastly-service.com" + +[[proxy.asset_routes]] +prefix = "/static/assets/" +origin_url = "https://assets.example.net" +``` + +### Field definitions + +#### `prefix` + +- required +- string +- must start with `/` +- matched against the request path only +- case-sensitive, using normal request-path semantics + +#### `origin_url` + +- required +- string +- absolute `http` or `https` URL +- must not include a trailing slash +- used as the upstream scheme/host/port base +- request path and query are preserved from the incoming request + +### Validation rules + +#### Hard validation errors + +These should fail configuration loading: + +- `prefix` missing +- `prefix` does not start with `/` +- `origin_url` missing +- `origin_url` is not an absolute `http`/`https` URL +- `origin_url` has a trailing slash + +#### Warning-only validation + +Duplicate exact prefixes should not fail startup. + +Instead: + +- log a warning for later duplicates +- keep behavior deterministic +- exact duplicate prefixes use the **first configured rule** + +This preserves production availability while surfacing misconfiguration. + +--- + +## Proposed Data Model + +Add a new route type under proxy settings. + +```rust +pub struct ProxyAssetRoute { + pub prefix: String, + pub origin_url: String, +} + +pub struct Proxy { + pub certificate_check: bool, + pub allowed_domains: Vec, + pub asset_routes: Vec, +} +``` + +### Runtime helper behavior + +A helper should normalize and validate asset routes during settings preparation. + +Recommended responsibilities: + +- validate each route +- warn on duplicate exact prefixes +- provide longest-prefix matching for a path +- provide deterministic duplicate behavior + +--- + +## Request Routing Design + +### Current baseline + +Today the top-level request router behaves roughly as follows: + +1. match built-in routes +2. match integration routes +3. otherwise proxy to `publisher.origin_url` + +### Proposed routing order + +1. match built-in Trusted Server routes +2. match integration routes +3. if method is `GET` or `HEAD`, try asset-route match +4. if asset route matched, proxy to that asset origin +5. otherwise fall through to existing publisher-origin proxy path + +### Why this placement + +This preserves current application route behavior while allowing targeted +origin overrides for fallback asset paths. + +Asset routes should not become a general-purpose top-level router that can +interfere with core product endpoints. + +--- + +## Matching Algorithm + +### Inputs + +- HTTP method +- request path +- configured `asset_routes` + +### Matching rules + +1. Ignore all asset routes unless method is `GET` or `HEAD` +2. Compare request path against each configured `prefix` +3. A route matches when `request_path.starts_with(prefix)` +4. Select the match with the longest `prefix` +5. If multiple routes have the same exact prefix, the first configured route + wins and later duplicates only warn + +### Examples + +#### Example 1: simple match + +Rules: + +- `/.images/` → `https://img.fastly.example` + +Request: + +- `GET /.images/photo.jpg?w=1000` + +Result: + +- proxy to `https://img.fastly.example/.images/photo.jpg?w=1000` + +#### Example 2: longest prefix + +Rules: + +- `/.images/` → A +- `/.images/special/` → B + +Request: + +- `GET /.images/special/banner.png` + +Result: + +- route B wins + +#### Example 3: wrong method + +Rules: + +- `/.images/` → A + +Request: + +- `POST /.images/upload` + +Result: + +- no asset-route match; continue existing routing behavior + +--- + +## Proxy Behavior + +### Upstream URL construction + +For a matched asset route: + +1. take the matched rule's `origin_url` +2. preserve the incoming request path exactly +3. preserve the incoming query string exactly +4. build the upstream request URL from those components + +Example: + +- origin: `https://some.fastly-service.com` +- path: `/.images/foo.jpg` +- query: `auto=webp&width=800` +- upstream: `https://some.fastly-service.com/.images/foo.jpg?auto=webp&width=800` + +### Backend selection + +The route should use the existing dynamic-backend mechanism already used +elsewhere in Trusted Server. + +Backend creation should be derived from the matched `origin_url` and +`settings.proxy.certificate_check`. + +### Host header + +The upstream `Host` header must be set to the matched asset origin host, +not the original first-party host. + +This is necessary for CDN and origin correctness. + +### Method forwarding + +- incoming `GET` → upstream `GET` +- incoming `HEAD` → upstream `HEAD` + +No method rewriting. + +### Header forwarding + +Forward a minimal curated set of request headers, aligned with existing proxy +helper behavior where possible. + +Recommended v1 header set: + +- `Accept` +- `Accept-Encoding` +- `Accept-Language` +- `User-Agent` +- `Referer` +- `X-Forwarded-For` + +Avoid broad header tunneling in v1. + +### Redirects + +Do not follow redirects. + +If upstream returns a redirect, return it to the client. + +### Response handling + +Treat the response as raw pass-through: + +- preserve status code +- preserve response body bytes +- preserve response headers, including cache headers +- do not inspect content type for rewriting +- do not run creative, HTML, CSS, or RSC processors + +--- + +## Interaction with Existing Publisher Proxy + +The existing publisher proxy path is HTML-aware and consent-aware. It includes: + +- cookie parsing +- EC generation / forwarding +- consent context construction +- response rewriting and post-processing +- origin fallback through `publisher.origin_url` + +The new asset-route path is intentionally separate. + +### Design principle + +Use the publisher proxy for pages and general publisher-origin traffic. +Use asset-route proxying for configured static/asset namespaces. + +This separation keeps the asset path lean and avoids introducing page-proxy +behavior into CDN-style traffic. + +--- + +## Failure Semantics + +### Upstream returns HTTP response + +Return it as-is. + +Examples: + +- `404 Not Found` → return `404` +- `500 Internal Server Error` → return `500` +- `302 Found` → return `302` + +### Upstream unreachable / backend failure + +Return the normal Trusted Server error behavior for backend/proxy failure. + +Do **not** retry against `publisher.origin_url`. +Do **not** silently fall back. + +### Misconfiguration + +- invalid `prefix` / invalid `origin_url` → configuration error +- duplicate exact `prefix` → warning only + +--- + +## Observability + +At minimum, log enough information to diagnose routing decisions. + +Recommended log points: + +- asset route matched: request path, matched prefix, target origin +- duplicate exact prefix detected at startup +- asset proxy backend creation failure +- asset upstream request failure +- asset route skipped due to unsupported method + +Logging should use the project's normal `log` macros. + +--- + +## Security Considerations + +### 1. Limited scope + +This feature is not an arbitrary open proxy. It only routes to origins that are +statically configured in `trusted-server.toml`. + +### 2. No redirect following + +Returning redirects as-is avoids introducing redirect-chain SSRF concerns for +this feature. + +### 3. Minimal header forwarding + +Forwarding a curated header set reduces risk from hop-by-hop headers or +unexpected application headers being tunneled upstream. + +### 4. No signed-URL trust expansion + +This feature does not reuse `/first-party/proxy` URL-signing behavior. It is a +separate static routing mechanism. + +--- + +## Acceptance Criteria + +### Configuration + +- `trusted-server.toml` accepts `[[proxy.asset_routes]]` +- each route requires `prefix` and `origin_url` +- invalid `prefix` fails config load +- invalid `origin_url` fails config load +- duplicate exact prefixes log warnings but do not fail startup + +### Routing + +- built-in routes still win over asset routes +- integration routes still win over asset routes +- asset routes are evaluated before publisher-origin fallback +- only `GET` and `HEAD` requests participate +- longest matching prefix wins +- exact duplicate prefixes resolve deterministically to the first configured rule + +### Proxy semantics + +- matched requests preserve path and query exactly +- matched requests use the asset origin's scheme/host/port +- upstream `Host` header matches asset origin host +- redirects are returned to the client, not followed +- cache headers pass through unchanged +- no fallback to `publisher.origin_url` on asset origin failure +- `HEAD` remains `HEAD` + +### Response processing + +- matched asset routes bypass publisher consent/cookie/rewriting logic +- matched asset routes behave as raw pass-through + +--- + +## Recommended Tests + +### Settings tests + +- parses multiple `[[proxy.asset_routes]]` entries +- rejects prefix without leading `/` +- rejects `origin_url` with trailing slash +- rejects non-absolute `origin_url` +- warns on duplicate exact prefixes + +### Route-selection tests + +- no match for unsupported method +- match by prefix +- longest-prefix wins +- exact duplicate prefix resolves to first rule +- query string does not affect matching + +### Adapter/router tests + +- built-in route precedence over asset route +- integration route precedence over asset route +- unmatched path still falls through to publisher proxy + +### Proxy-construction tests + +- path preserved exactly +- query preserved exactly +- upstream host header uses asset origin host +- `HEAD` preserved +- redirect response returned as-is + +--- + +## Implementation Notes + +A minimal implementation should avoid changing the existing publisher proxy +behavior more than necessary. + +Recommended implementation outline: + +1. Add `ProxyAssetRoute` and `Proxy.asset_routes` to settings +2. Add normalization / validation / duplicate-warning logic +3. Add a path-matching helper that selects the longest prefix +4. Add a lean asset-proxy handler that: + - builds a backend from matched `origin_url` + - preserves path + query + - forwards a minimal header set + - does not follow redirects + - returns raw upstream response +5. Insert asset-route handling into top-level routing after explicit routes and + before publisher fallback +6. Add focused tests for config, matching, precedence, and proxy construction + +--- + +## Future Extensions + +Potential future work, intentionally excluded from v1: + +- regex path matching +- path rewrite rules +- per-route custom headers +- per-route cache overrides +- per-route certificate-check options +- per-route method allowlists +- route metrics / counters +- fallback chains across multiple origins + +--- + +## Open Questions + +None blocking for v1. + +The only follow-up item already identified is broader project-wide work to make +misconfiguration handling more consistent across Trusted Server, but that is not +required to implement this feature. diff --git a/trusted-server.toml b/trusted-server.toml index f57e9146..a7278a3d 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -12,6 +12,9 @@ password = "changeme" domain = "test-publisher.com" cookie_domain = ".test-publisher.com" origin_url = "https://origin.test-publisher.com" +# Optional: override the upstream Host header while still connecting to origin_url. +# Useful when an origin routes sites by Host but the backend DNS name differs. +# origin_host_header = "test-publisher.com" proxy_secret = "change-me-proxy-secret" [edge_cookie] @@ -167,6 +170,32 @@ rewrite_script = true # Defaults to true. Set to false only for local development with self-signed certificates. # certificate_check = true +# Configure first-party asset paths that should proxy to a different backend origin. +# Matching is path-prefix-based and the longest matching prefix wins. +# Include a trailing / unless you intentionally want /static to also match paths such as /staticfile.js. +# Only GET/HEAD requests participate. Built-in and integration routes still take precedence. +# Trusted Server preserves the incoming query string. By default it also preserves +# the incoming path, but path_pattern/target_path can generically rewrite paths +# before sending them upstream. +# +# [[proxy.asset_routes]] +# prefix = "/.images/" +# origin_url = "https://some.fastly-service.com" +# +# Example: CDN-style first-party image path rewrite. +# [[proxy.asset_routes]] +# prefix = "/.image/" +# origin_url = "https://assets-cdn.example.com" +# path_pattern = "^/\\.image/(.*)/[^/]+\\.([^/.]+)$" +# target_path = "/image/upload/$1.$2" +# +# Example: shared static assets stored under an upstream /_network prefix. +# [[proxy.asset_routes]] +# prefix = "/_next/static/" +# origin_url = "https://static-assets.example.com" +# path_pattern = "^(.*)$" +# target_path = "/_network$1" +# # Restrict redirect destinations for the first-party proxy to an explicit domain allowlist. # Supports exact match ("example.com") and subdomain wildcard prefix ("*.example.com"). # Wildcard prefix also matches the apex domain ("*.example.com" matches "example.com"). From 51dd26f04a7e4ec0d4f34f5effd7611d854beada Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 11 May 2026 12:47:58 -0500 Subject: [PATCH 2/4] Address asset proxy review feedback --- .../trusted-server-adapter-fastly/src/main.rs | 10 +- .../src/route_tests.rs | 78 ++++---- crates/trusted-server-core/src/backend.rs | 77 +------ crates/trusted-server-core/src/proxy.rs | 83 ++++---- crates/trusted-server-core/src/publisher.rs | 25 +-- crates/trusted-server-core/src/settings.rs | 189 +++--------------- ...-04-28-multi-backend-asset-proxy-design.md | 80 ++++++-- trusted-server.toml | 3 - 8 files changed, 180 insertions(+), 365 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 3d8942f3..bdb755b3 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -207,10 +207,6 @@ async fn route_request( let path = req.get_path().to_string(); let method = req.get_method().clone(); - let matched_asset_route = matches!(method, Method::GET | Method::HEAD) - .then(|| settings.asset_route_for_path(&path)) - .flatten(); - // Match known routes and handle them let result = match (method, path.as_str()) { // Serve the tsjs library @@ -268,7 +264,11 @@ async fn route_request( }), // No known route matched, proxy to an asset origin or publisher origin as fallback - _ => { + (method, _) => { + let matched_asset_route = matches!(method, Method::GET | Method::HEAD) + .then(|| settings.asset_route_for_path(&path)) + .flatten(); + if let Some(asset_route) = matched_asset_route { log::info!( "No explicit route matched for path: {}, proxying via asset route prefix {} to {}", diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 64f3cec9..b08d43a6 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -353,11 +353,10 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { #[test] fn asset_routes_bypass_publisher_consent_dependencies() { let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }]; + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/.images/", + "https://assets.example.com", + )]; let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -382,11 +381,10 @@ fn asset_routes_bypass_publisher_consent_dependencies() { #[test] fn asset_origin_failure_does_not_fall_back_to_publisher_origin() { let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }]; + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/.images/", + "https://assets.example.com", + )]; let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -426,11 +424,10 @@ fn asset_origin_failure_does_not_fall_back_to_publisher_origin() { #[test] fn asset_routes_proxy_head_requests() { let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }]; + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/.images/", + "https://assets.example.com", + )]; let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -477,11 +474,10 @@ fn asset_routes_proxy_head_requests() { #[test] fn asset_routes_ignore_query_string_for_matching() { let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }]; + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/.images/", + "https://assets.example.com", + )]; let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -523,11 +519,10 @@ fn asset_routes_ignore_query_string_for_matching() { #[test] fn asset_routes_pass_redirect_responses_through() { let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }]; + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/.images/", + "https://assets.example.com", + )]; let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -569,11 +564,10 @@ fn asset_routes_pass_redirect_responses_through() { #[test] fn asset_routes_skip_non_get_head_requests() { let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }]; + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/.images/", + "https://assets.example.com", + )]; let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -595,10 +589,10 @@ fn asset_routes_skip_non_get_head_requests() { )) .expect("should route non-asset POST request"); - assert_ne!( + assert_eq!( resp.get_status(), - StatusCode::OK, - "should not return the asset-origin response for POST requests" + StatusCode::SERVICE_UNAVAILABLE, + "should fall through to publisher fallback for POST requests" ); assert!( http_client @@ -613,11 +607,10 @@ fn asset_routes_skip_non_get_head_requests() { #[test] fn built_in_routes_take_precedence_over_asset_routes() { let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute { - prefix: "/.well-known/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }]; + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/.well-known/", + "https://assets.example.com", + )]; let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -642,11 +635,10 @@ fn built_in_routes_take_precedence_over_asset_routes() { #[test] fn integration_routes_take_precedence_over_asset_routes() { let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute { - prefix: "/prebid.js".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }]; + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/prebid.js", + "https://assets.example.com", + )]; let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); diff --git a/crates/trusted-server-core/src/backend.rs b/crates/trusted-server-core/src/backend.rs index 808bbcfe..468a3f83 100644 --- a/crates/trusted-server-core/src/backend.rs +++ b/crates/trusted-server-core/src/backend.rs @@ -46,7 +46,6 @@ pub struct BackendConfig<'a> { port: Option, certificate_check: bool, first_byte_timeout: Duration, - override_host: Option<&'a str>, } impl<'a> BackendConfig<'a> { @@ -62,7 +61,6 @@ impl<'a> BackendConfig<'a> { port: None, certificate_check: true, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, - override_host: None, } } @@ -81,14 +79,6 @@ impl<'a> BackendConfig<'a> { self } - /// Override the Host header sent upstream while keeping the backend target, - /// TLS SNI, and certificate verification tied to [`Self::host`]. - #[must_use] - pub fn override_host(mut self, override_host: Option<&'a str>) -> Self { - self.override_host = override_host; - self - } - /// Set the maximum time to wait for the first byte of the response. /// /// Defaults to 15 seconds. For latency-sensitive paths like auction @@ -119,14 +109,6 @@ impl<'a> BackendConfig<'a> { message: "host contains control characters".to_string(), })); } - if self - .override_host - .is_some_and(|host| host.is_empty() || host.chars().any(char::is_control)) - { - return Err(Report::new(TrustedServerError::Proxy { - message: "override host is empty or contains control characters".to_string(), - })); - } if self.scheme.chars().any(char::is_control) { return Err(Report::new(TrustedServerError::Proxy { message: "scheme contains control characters".to_string(), @@ -143,17 +125,11 @@ impl<'a> BackendConfig<'a> { } else { "_nocert" }; - let override_host_suffix = self - .override_host - .filter(|host| !host.is_empty()) - .map(|host| format!("_oh_{}", host.replace(['.', ':'], "_"))) - .unwrap_or_default(); let timeout_ms = self.first_byte_timeout.as_millis(); let backend_name = format!( - "backend_{}{}{}_t{}", + "backend_{}{}_t{}", name_base.replace(['.', ':'], "_"), cert_suffix, - override_host_suffix, timeout_ms ); @@ -189,12 +165,11 @@ impl<'a> BackendConfig<'a> { let host_with_port = format!("{}:{}", self.host, target_port); - let default_host_header = compute_host_header(self.scheme, self.host, target_port); - let host_header = self.override_host.unwrap_or(&default_host_header); + let host_header = compute_host_header(self.scheme, self.host, target_port); // Target base is host[:port]; SSL is enabled only for https scheme let mut builder = Backend::builder(&backend_name, &host_with_port) - .override_host(host_header) + .override_host(&host_header) .connect_timeout(Duration::from_secs(1)) .first_byte_timeout(self.first_byte_timeout) .between_bytes_timeout(Duration::from_secs(10)); @@ -303,26 +278,6 @@ impl<'a> BackendConfig<'a> { origin_url: &str, certificate_check: bool, first_byte_timeout: Duration, - ) -> Result> { - Self::from_url_with_first_byte_timeout_and_override_host( - origin_url, - certificate_check, - first_byte_timeout, - None, - ) - } - - /// Parse an origin URL and ensure a dynamic backend with an optional upstream Host override. - /// - /// # Errors - /// - /// Returns an error if the URL cannot be parsed or lacks a host, or if - /// backend creation fails. - pub fn from_url_with_first_byte_timeout_and_override_host( - origin_url: &str, - certificate_check: bool, - first_byte_timeout: Duration, - override_host: Option<&str>, ) -> Result> { let (scheme, host, port) = Self::parse_origin(origin_url)?; @@ -330,7 +285,6 @@ impl<'a> BackendConfig<'a> { .port(port) .certificate_check(certificate_check) .first_byte_timeout(first_byte_timeout) - .override_host(override_host) .ensure() } @@ -446,31 +400,6 @@ mod tests { assert_eq!(name, "backend_http_example_org_80_t15000"); } - #[test] - fn override_host_changes_backend_name() { - let (name, _) = BackendConfig::new("https", "backend.example.net") - .override_host(Some("www.example.com")) - .compute_name() - .expect("should compute name with Host override"); - - assert_eq!( - name, "backend_https_backend_example_net_443_oh_www_example_com_t15000", - "should isolate dynamic backends with different Host overrides" - ); - } - - #[test] - fn error_on_override_host_with_control_characters() { - let err = BackendConfig::new("https", "origin.example.com") - .override_host(Some("www.example.com\nINFO fake log entry")) - .predict_name() - .expect_err("should reject override host containing newline"); - assert!( - err.to_string().contains("override host"), - "should report invalid override host in error message" - ); - } - #[test] fn error_on_host_with_control_characters() { let err = BackendConfig::new("https", "evil.com\nINFO fake log entry") diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index dc71b867..6487d5ce 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -43,13 +43,14 @@ const PROXY_FORWARD_HEADERS: [header::HeaderName; 5] = [ /// /// Unlike the HTML publisher fallback, asset requests need cache validation and /// byte-range semantics to keep 304/206 responses working for browsers. -const ASSET_PROXY_FORWARD_HEADERS: [header::HeaderName; 12] = [ +/// Client-supplied forwarding headers are stripped at the edge and are not +/// reconstructed here, so asset origins see Trusted Server as the client. +const ASSET_PROXY_FORWARD_HEADERS: [header::HeaderName; 11] = [ HEADER_USER_AGENT, HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER, - HEADER_X_FORWARDED_FOR, header::IF_NONE_MATCH, header::IF_MODIFIED_SINCE, header::IF_MATCH, @@ -58,6 +59,9 @@ const ASSET_PROXY_FORWARD_HEADERS: [header::HeaderName; 12] = [ header::IF_RANGE, ]; +const ASSET_PROXY_STRIP_RESPONSE_HEADERS: [&str; 3] = + ["set-cookie", "strict-transport-security", "clear-site-data"]; + /// Convert a platform-neutral response into a [`fastly::Response`] for downstream processing. /// /// Shared with `auction/orchestrator.rs`. Both files will migrate off `fastly::Response` @@ -655,10 +659,11 @@ pub async fn handle_asset_proxy_request( let mut response = platform_response_to_fastly(platform_resp)?; - // Asset origins must not be able to set first-party cookies or publisher - // domain transport security policy through this proxy path. - response.remove_header(header::SET_COOKIE); - response.remove_header(header::STRICT_TRANSPORT_SECURITY); + // Asset origins must not be able to mutate publisher-domain browser state + // or security policy through this proxy path. + for header_name in ASSET_PROXY_STRIP_RESPONSE_HEADERS { + response.remove_header(header_name); + } Ok(response) } @@ -1425,7 +1430,7 @@ mod tests { reconstruct_and_validate_signed_target, redirect_is_permitted, ProxyRequestConfig, SUPPORTED_ENCODINGS, }; - use crate::constants::HEADER_ACCEPT; + use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; use crate::platform::test_support::{ @@ -2264,11 +2269,7 @@ mod tests { #[test] fn build_asset_proxy_target_url_preserves_path_and_query() { - let route = ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }; + let route = ProxyAssetRoute::new("/.images/", "https://assets.example.com"); let target_url = build_asset_proxy_target_url(&route, "/.images/foo.jpg", "auto=webp&width=800") .expect("should build asset target URL"); @@ -2282,12 +2283,9 @@ mod tests { #[test] fn build_asset_proxy_target_url_applies_cdn_style_rewrite() { - let route = ProxyAssetRoute { - prefix: "/.image/".to_string(), - origin_url: "https://assets-cdn.example.com".to_string(), - path_pattern: Some(r"^/\.image/(.*)/[^/]+\.([^/.]+)$".to_string()), - target_path: Some("/image/upload/$1.$2".to_string()), - }; + let mut route = ProxyAssetRoute::new("/.image/", "https://assets-cdn.example.com"); + route.path_pattern = Some(r"^/\.image/(.*)/[^/]+\.([^/.]+)$".to_string()); + route.target_path = Some("/image/upload/$1.$2".to_string()); let target_url = build_asset_proxy_target_url( &route, "/.image/c_fit,w_1440/MjA/example.jpg", @@ -2304,12 +2302,9 @@ mod tests { #[test] fn build_asset_proxy_target_url_applies_static_prefix_rewrite() { - let route = ProxyAssetRoute { - prefix: "/_next/static/".to_string(), - origin_url: "https://static-assets.example.com".to_string(), - path_pattern: Some(r"^(.*)$".to_string()), - target_path: Some("/_network$1".to_string()), - }; + let mut route = ProxyAssetRoute::new("/_next/static/", "https://static-assets.example.com"); + route.path_pattern = Some(r"^(.*)$".to_string()); + route.target_path = Some("/_network$1".to_string()); let target_url = build_asset_proxy_target_url(&route, "/_next/static/chunks/app.js", "") .expect("should build rewritten static asset target URL"); @@ -2322,12 +2317,9 @@ mod tests { #[test] fn build_asset_proxy_target_url_errors_when_rewrite_pattern_misses() { - let route = ProxyAssetRoute { - prefix: "/.image/".to_string(), - origin_url: "https://assets.example.com".to_string(), - path_pattern: Some(r"^/\.image/(.*)\.jpg$".to_string()), - target_path: Some("/image/upload/$1.jpg".to_string()), - }; + let mut route = ProxyAssetRoute::new("/.image/", "https://assets.example.com"); + route.path_pattern = Some(r"^/\.image/(.*)\.jpg$".to_string()); + route.target_path = Some("/image/upload/$1.jpg".to_string()); let err = build_asset_proxy_target_url(&route, "/.image/foo.png", "") .expect_err("should reject paths that do not match the configured rewrite"); @@ -2339,12 +2331,9 @@ mod tests { #[test] fn build_asset_proxy_target_url_errors_when_rewrite_omits_leading_slash() { - let route = ProxyAssetRoute { - prefix: "/assets/".to_string(), - origin_url: "https://assets.example.com".to_string(), - path_pattern: Some(r"^/assets/(.*)$".to_string()), - target_path: Some("$1".to_string()), - }; + let mut route = ProxyAssetRoute::new("/assets/", "https://assets.example.com"); + route.path_pattern = Some(r"^/assets/(.*)$".to_string()); + route.target_path = Some("$1".to_string()); let err = build_asset_proxy_target_url(&route, "/assets/app.js", "") .expect_err("should reject rewritten paths without a leading slash"); @@ -2403,13 +2392,10 @@ mod tests { req.set_header(header::IF_UNMODIFIED_SINCE, "Thu, 13 Mar 2025 09:00:00 GMT"); req.set_header(header::RANGE, "bytes=0-1023"); req.set_header(header::IF_RANGE, "\"asset-range\""); + req.set_header(HEADER_X_FORWARDED_FOR, "198.51.100.10"); req.set_header(header::HeaderName::from_static("x-custom-test"), "drop-me"); - let route = ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com:8443".to_string(), - ..Default::default() - }; + let route = ProxyAssetRoute::new("/.images/", "https://assets.example.com:8443"); let response = handle_asset_proxy_request(&settings, &services, req, &route) .await .expect("should proxy asset request"); @@ -2467,6 +2453,10 @@ mod tests { Some("assets.example.com:8443"), "should override Host to the asset origin host" ); + assert!( + header_value("x-forwarded-for").is_none(), + "should not forward client-supplied X-Forwarded-For" + ); assert!( header_value("x-custom-test").is_none(), "should not forward unrelated custom headers" @@ -2486,6 +2476,7 @@ mod tests { header::STRICT_TRANSPORT_SECURITY.as_str(), "max-age=31536000; includeSubDomains; preload", ), + ("clear-site-data", "\"*\""), (header::ETAG.as_str(), "\"asset-etag\""), ], ); @@ -2495,11 +2486,7 @@ mod tests { let settings = create_test_settings(); let req = Request::new(Method::GET, "https://www.example.com/.images/foo.jpg"); - let route = ProxyAssetRoute { - prefix: "/.images/".to_string(), - origin_url: "https://assets.example.com".to_string(), - ..Default::default() - }; + let route = ProxyAssetRoute::new("/.images/", "https://assets.example.com"); let response = handle_asset_proxy_request(&settings, &services, req, &route) .await .expect("should proxy asset request"); @@ -2514,6 +2501,10 @@ mod tests { .is_none(), "should strip upstream HSTS headers from asset responses" ); + assert!( + response.get_header("clear-site-data").is_none(), + "should strip upstream Clear-Site-Data headers from asset responses" + ); assert_eq!( response.get_header_str(header::ETAG), Some("\"asset-etag\""), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 3caf136d..dfce3733 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -516,24 +516,19 @@ pub fn handle_publisher_request( let ec_allowed = allows_ec_creation(&consent_context); log::debug!("Proxy ec_allowed: {}", ec_allowed); - let origin_host_header = settings.publisher.origin_host_header_value(); - let origin_rewrite_url = settings.publisher.origin_rewrite_url(); - let backend_name = BackendConfig::from_url_with_first_byte_timeout_and_override_host( + let backend_name = BackendConfig::from_url( &settings.publisher.origin_url, settings.proxy.certificate_check, - crate::backend::DEFAULT_FIRST_BYTE_TIMEOUT, - Some(&origin_host_header), )?; + let origin_host = settings.publisher.origin_host(); log::debug!( - "Proxying to dynamic backend: {} (from {}, Host: {})", + "Proxying to dynamic backend: {} (from {})", backend_name, - settings.publisher.origin_url, - origin_host_header + settings.publisher.origin_url ); // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); - req.set_header("host", &origin_host_header); let mut response = req .send(&backend_name) @@ -618,7 +613,7 @@ pub fn handle_publisher_request( ResponseRoute::Stream => { log::debug!( "Streaming response - Content-Type: {}, Content-Encoding: {}, Request Host: {}, Origin Host: {}", - content_type, content_encoding, request_host, origin_host_header + content_type, content_encoding, request_host, origin_host ); let body = response.take_body(); @@ -629,8 +624,8 @@ pub fn handle_publisher_request( body, params: OwnedProcessResponseParams { content_encoding, - origin_host: origin_host_header, - origin_url: origin_rewrite_url, + origin_host, + origin_url: settings.publisher.origin_url.clone(), request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, @@ -640,14 +635,14 @@ pub fn handle_publisher_request( ResponseRoute::BufferedProcessed => { log::debug!( "Buffered response - Content-Type: {}, Content-Encoding: {}, Request Host: {}, Origin Host: {}", - content_type, content_encoding, request_host, origin_host_header + content_type, content_encoding, request_host, origin_host ); let body = response.take_body(); let params = ProcessResponseParams { content_encoding: &content_encoding, - origin_host: &origin_host_header, - origin_url: &origin_rewrite_url, + origin_host: &origin_host, + origin_url: &settings.publisher.origin_url, request_host, request_scheme, settings, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index ea727d00..0c1aa38a 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -24,9 +24,6 @@ pub struct Publisher { pub cookie_domain: String, #[validate(custom(function = validate_no_trailing_slash))] pub origin_url: String, - /// Optional upstream Host header value used when connecting to an origin - /// whose routing host differs from the backend host. - pub origin_host_header: Option, /// Secret used to encrypt/decrypt proxied URLs in `/first-party/proxy`. /// Keep this secret stable to allow existing links to decode. #[validate(custom(function = validate_redacted_not_empty))] @@ -46,61 +43,6 @@ impl Publisher { .any(|p| p.eq_ignore_ascii_case(proxy_secret)) } - fn normalize(&mut self) { - self.origin_host_header = self - .origin_host_header - .take() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - } - - /// Eagerly validate runtime-only publisher configuration. - /// - /// # Errors - /// - /// Returns a configuration error if the configured origin Host header is invalid. - pub fn prepare_runtime(&self) -> Result<(), Report> { - if let Some(host_header) = &self.origin_host_header { - validate_host_header_value(host_header).map_err(|err| { - Report::new(TrustedServerError::Configuration { - message: format!( - "publisher.origin_host_header `{host_header}` is invalid: {err}" - ), - }) - })?; - } - - Ok(()) - } - - /// Returns the upstream Host header to send to the publisher origin. - #[must_use] - pub fn origin_host_header_value(&self) -> String { - self.origin_host_header - .clone() - .unwrap_or_else(|| self.origin_host()) - } - - /// Returns the public origin URL whose URLs should be rewritten to the request host. - /// - /// When `origin_host_header` is configured, the backend connection target - /// (`origin_url`) may be an internal routing host while page content still - /// references the public origin host. In that case, rewrite against the - /// configured Host header using the origin URL's scheme. - #[must_use] - pub fn origin_rewrite_url(&self) -> String { - let Some(host_header) = self.origin_host_header.as_deref() else { - return self.origin_url.clone(); - }; - - let scheme = Url::parse(&self.origin_url) - .ok() - .map(|url| url.scheme().to_string()) - .unwrap_or_else(|| "https".to_string()); - - format!("{scheme}://{host_header}") - } - /// Extracts the host (including port if present) from the `origin_url`. /// /// # Examples @@ -112,7 +54,6 @@ impl Publisher { /// domain: "example.com".to_string(), /// cookie_domain: ".example.com".to_string(), /// origin_url: "https://origin.example.com:8080".to_string(), - /// origin_host_header: None, /// proxy_secret: Redacted::new("proxy-secret".to_string()), /// }; /// assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -414,9 +355,21 @@ pub struct ProxyAssetRoute { /// Must be configured together with [`Self::path_pattern`] and must produce a /// path that starts with `/`. pub target_path: Option, + #[serde(skip, default)] + compiled_pattern: OnceLock>, } impl ProxyAssetRoute { + /// Create an asset route with the required prefix and origin URL. + #[must_use] + pub fn new(prefix: impl Into, origin_url: impl Into) -> Self { + Self { + prefix: prefix.into(), + origin_url: origin_url.into(), + ..Self::default() + } + } + fn normalize(&mut self) { self.prefix = self.prefix.trim().to_string(); self.origin_url = self.origin_url.trim().to_string(); @@ -432,18 +385,22 @@ impl ProxyAssetRoute { .filter(|value| !value.is_empty()); } - fn compiled_path_pattern(&self) -> Result, Report> { + fn compiled_path_pattern(&self) -> Result, Report> { let Some(pattern) = self.path_pattern.as_deref() else { return Ok(None); }; - Regex::new(pattern).map(Some).map_err(|err| { - Report::new(TrustedServerError::Configuration { + match self + .compiled_pattern + .get_or_init(|| Regex::new(pattern).map_err(|err| err.to_string())) + { + Ok(regex) => Ok(Some(regex)), + Err(message) => Err(Report::new(TrustedServerError::Configuration { message: format!( - "proxy.asset_routes path_pattern `{pattern}` failed to compile: {err}" + "proxy.asset_routes path_pattern `{pattern}` failed to compile: {message}" ), - }) - }) + })), + } } /// Rewrite a matched request path to the configured upstream target path. @@ -456,14 +413,14 @@ impl ProxyAssetRoute { match (&self.path_pattern, &self.target_path) { (None, None) => Ok(path.to_string()), (Some(_), Some(target_path)) => { - let regex = self.compiled_path_pattern()?.ok_or_else(|| { - Report::new(TrustedServerError::Configuration { + let Some(regex) = self.compiled_path_pattern()? else { + return Err(Report::new(TrustedServerError::Configuration { message: format!( - "proxy.asset_routes prefix `{}` has a target_path without path_pattern", + "proxy.asset_routes prefix `{}` must configure path_pattern and target_path together", self.prefix ), - }) - })?; + })); + }; if !regex.is_match(path) { return Err(Report::new(TrustedServerError::Proxy { @@ -711,7 +668,6 @@ impl Settings { message: "Failed to deserialize TOML configuration".to_string(), })?; - settings.publisher.normalize(); settings.proxy.normalize(); settings.consent.validate(); settings.prepare_runtime()?; @@ -750,7 +706,6 @@ impl Settings { })?; settings.integrations.normalize(); - settings.publisher.normalize(); settings.proxy.normalize(); settings.consent.validate(); @@ -772,7 +727,6 @@ impl Settings { /// /// Returns a configuration error if any cached runtime artifact cannot be prepared. pub fn prepare_runtime(&self) -> Result<(), Report> { - self.publisher.prepare_runtime()?; self.proxy.prepare_runtime()?; for handler in &self.handlers { @@ -909,18 +863,6 @@ fn validate_redacted_not_empty(value: &Redacted) -> Result<(), Validatio Ok(()) } -fn validate_host_header_value(value: &str) -> Result<(), ValidationError> { - if value.is_empty() || value.contains(['\0', '\n', '\r']) { - let mut err = ValidationError::new("invalid_host_header"); - err.add_param("value".into(), &value); - err.message = - Some("host header must be non-empty and must not contain control characters".into()); - return Err(err); - } - - Ok(()) -} - fn validate_asset_route_prefix(value: &str) -> Result<(), ValidationError> { if !value.starts_with('/') { let mut err = ValidationError::new("invalid_prefix"); @@ -1680,7 +1622,6 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "https://origin.example.com:8080".to_string(), - origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -1690,7 +1631,6 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "https://origin.example.com".to_string(), - origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "origin.example.com"); @@ -1700,7 +1640,6 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://localhost:9090".to_string(), - origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -1710,7 +1649,6 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "localhost:9090".to_string(), - origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -1720,7 +1658,6 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://192.168.1.1:8080".to_string(), - origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "192.168.1.1:8080"); @@ -1730,82 +1667,11 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://[::1]:8080".to_string(), - origin_host_header: None, proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "[::1]:8080"); } - #[test] - fn publisher_origin_host_header_defaults_to_origin_host() { - let publisher = Publisher { - domain: "example.com".to_string(), - cookie_domain: ".example.com".to_string(), - origin_url: "https://origin.example.com".to_string(), - origin_host_header: None, - proxy_secret: Redacted::new("test-secret".to_string()), - }; - - assert_eq!( - publisher.origin_host_header_value(), - "origin.example.com", - "should preserve existing Host header behavior by default" - ); - } - - #[test] - fn publisher_origin_host_header_uses_configured_value() { - let mut publisher = Publisher { - domain: "example.com".to_string(), - cookie_domain: ".example.com".to_string(), - origin_url: "https://backend.example.net".to_string(), - origin_host_header: Some(" example.com ".to_string()), - proxy_secret: Redacted::new("test-secret".to_string()), - }; - publisher.normalize(); - - assert_eq!( - publisher.origin_host_header_value(), - "example.com", - "should use the normalized configured upstream Host header" - ); - } - - #[test] - fn publisher_origin_rewrite_url_uses_configured_host_with_origin_scheme() { - let mut publisher = Publisher { - domain: "example.com".to_string(), - cookie_domain: ".example.com".to_string(), - origin_url: "https://backend.example.net".to_string(), - origin_host_header: Some("www.example.com".to_string()), - proxy_secret: Redacted::new("test-secret".to_string()), - }; - publisher.normalize(); - - assert_eq!( - publisher.origin_rewrite_url(), - "https://www.example.com", - "should rewrite public-origin URLs instead of backend routing host URLs" - ); - } - - #[test] - fn publisher_origin_rewrite_url_defaults_to_origin_url_without_host_override() { - let publisher = Publisher { - domain: "example.com".to_string(), - cookie_domain: ".example.com".to_string(), - origin_url: "https://origin.example.com".to_string(), - origin_host_header: None, - proxy_secret: Redacted::new("test-secret".to_string()), - }; - - assert_eq!( - publisher.origin_rewrite_url(), - "https://origin.example.com", - "should preserve existing rewrite behavior without a Host override" - ); - } - #[test] fn test_integration_settings_from_env() { use crate::integrations::testlight::TestlightConfig; @@ -2290,6 +2156,7 @@ mod tests { origin_url: "https://assets.example.com".to_string(), path_pattern: Some(" ^/(.*)$ ".to_string()), target_path: Some(" /rewritten/$1 ".to_string()), + ..Default::default() }], }; proxy.normalize(); diff --git a/docs/superpowers/specs/2026-04-28-multi-backend-asset-proxy-design.md b/docs/superpowers/specs/2026-04-28-multi-backend-asset-proxy-design.md index 730360d2..423d2847 100644 --- a/docs/superpowers/specs/2026-04-28-multi-backend-asset-proxy-design.md +++ b/docs/superpowers/specs/2026-04-28-multi-backend-asset-proxy-design.md @@ -46,16 +46,16 @@ certain path prefixes to a different backend. - Multiple configured asset-route rules - Per-rule alternate `origin_url` - Transparent proxying for ordinary inbound `GET`/`HEAD` requests -- Preservation of the incoming path and query string -- Raw response pass-through from the matched asset origin +- Preservation of the incoming path and query string by default +- Optional regex-based path rewrite after prefix route selection +- Raw response pass-through from the matched asset origin, except unsafe publisher-domain state/security headers - Deterministic longest-prefix route selection - Request routing that happens after built-in and integration routes, but before publisher-origin fallback ### Out of scope -- Regex-based route matching -- Path rewrite / prefix replacement +- Regex-based route selection - Cookie, consent, HTML, CSS, or JS rewriting on asset-route responses - Redirect following for asset routes - Special cache policy overrides @@ -79,7 +79,7 @@ proxies it directly to that route's configured origin. ### 2. Match on simple path prefixes -Routes are configured as simple prefixes, not regexes. +Routes are selected by simple prefixes, not regexes. Optional regexes may rewrite the upstream path only after a prefix route has already matched. Examples: @@ -91,17 +91,28 @@ Examples: Rule matching is performed against the request path only. Query strings are ignored for matching. -### 3. Preserve path and query exactly +### 3. Preserve path and query by default, with optional path rewrite -When a rule matches, Trusted Server replaces only the upstream origin -(scheme/host/port) and preserves the rest of the request URL exactly. +When a rule matches without rewrite settings, Trusted Server replaces only the +upstream origin (scheme/host/port) and preserves the rest of the request URL +exactly. Example: - inbound: `/.images/foo/bar.jpg?auto=webp&width=1200` - upstream path/query: `/.images/foo/bar.jpg?auto=webp&width=1200` -There is no path rewrite in v1. +Routes may optionally configure `path_pattern` and `target_path` together. In +that case, `path_pattern` is matched against the incoming request path after the +prefix route has been selected, and `target_path` is used as the regex +replacement for the upstream path. The incoming query string is still preserved. + +Example: + +- inbound: `/.images/foo/bar.jpg?auto=webp&width=1200` +- `path_pattern`: `^/\.images/(.*)$` +- `target_path`: `/cdn/$1` +- upstream path/query: `/cdn/foo/bar.jpg?auto=webp&width=1200` ### 4. Multiple rules supported @@ -232,7 +243,24 @@ origin_url = "https://assets.example.net" - absolute `http` or `https` URL - must not include a trailing slash - used as the upstream scheme/host/port base -- request path and query are preserved from the incoming request +- request query is preserved from the incoming request +- request path is preserved unless `path_pattern` / `target_path` rewrite it + +#### `path_pattern` + +- optional +- string +- regex matched against the incoming request path after prefix route selection +- must be configured together with `target_path` +- does not participate in route selection + +#### `target_path` + +- optional +- string +- regex replacement applied to `path_pattern` matches +- must be configured together with `path_pattern` +- replacement output must start with `/` ### Validation rules @@ -245,6 +273,9 @@ These should fail configuration loading: - `origin_url` missing - `origin_url` is not an absolute `http`/`https` URL - `origin_url` has a trailing slash +- `path_pattern` is configured without `target_path`, or vice versa +- `path_pattern` does not compile as a regex +- `target_path` rewrite output does not start with `/` #### Warning-only validation @@ -446,11 +477,15 @@ If upstream returns a redirect, return it to the client. ### Response handling -Treat the response as raw pass-through: +Treat the response as raw pass-through except for publisher-domain state and +security headers that asset origins must not control: - preserve status code - preserve response body bytes - preserve response headers, including cache headers +- strip `Set-Cookie` +- strip `Strict-Transport-Security` +- strip `Clear-Site-Data` - do not inspect content type for rewriting - do not run creative, HTML, CSS, or RSC processors @@ -542,6 +577,13 @@ unexpected application headers being tunneled upstream. This feature does not reuse `/first-party/proxy` URL-signing behavior. It is a separate static routing mechanism. +### 5. Asset origins cannot mutate publisher browser state + +Because responses are served on the publisher first-party domain, asset origins +must not be able to set cookies, alter transport-security policy, or clear +publisher storage. Asset responses therefore strip `Set-Cookie`, +`Strict-Transport-Security`, and `Clear-Site-Data`. + --- ## Acceptance Criteria @@ -565,7 +607,7 @@ separate static routing mechanism. ### Proxy semantics -- matched requests preserve path and query exactly +- matched requests preserve path and query exactly unless optional rewrite settings change the path - matched requests use the asset origin's scheme/host/port - upstream `Host` header matches asset origin host - redirects are returned to the client, not followed @@ -576,7 +618,7 @@ separate static routing mechanism. ### Response processing - matched asset routes bypass publisher consent/cookie/rewriting logic -- matched asset routes behave as raw pass-through +- matched asset routes behave as raw pass-through except unsafe publisher-domain state/security headers are stripped --- @@ -589,6 +631,7 @@ separate static routing mechanism. - rejects `origin_url` with trailing slash - rejects non-absolute `origin_url` - warns on duplicate exact prefixes +- rejects invalid `path_pattern` / `target_path` combinations ### Route-selection tests @@ -606,7 +649,8 @@ separate static routing mechanism. ### Proxy-construction tests -- path preserved exactly +- path preserved exactly without rewrite settings +- path rewritten when `path_pattern` and `target_path` are configured - query preserved exactly - upstream host header uses asset origin host - `HEAD` preserved @@ -626,10 +670,11 @@ Recommended implementation outline: 3. Add a path-matching helper that selects the longest prefix 4. Add a lean asset-proxy handler that: - builds a backend from matched `origin_url` - - preserves path + query + - preserves path + query by default + - applies optional path rewrite while preserving query - forwards a minimal header set - does not follow redirects - - returns raw upstream response + - returns raw upstream response after stripping unsafe publisher-domain state/security headers 5. Insert asset-route handling into top-level routing after explicit routes and before publisher fallback 6. Add focused tests for config, matching, precedence, and proxy construction @@ -640,8 +685,7 @@ Recommended implementation outline: Potential future work, intentionally excluded from v1: -- regex path matching -- path rewrite rules +- regex route selection - per-route custom headers - per-route cache overrides - per-route certificate-check options diff --git a/trusted-server.toml b/trusted-server.toml index a7278a3d..97bccbd5 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -12,9 +12,6 @@ password = "changeme" domain = "test-publisher.com" cookie_domain = ".test-publisher.com" origin_url = "https://origin.test-publisher.com" -# Optional: override the upstream Host header while still connecting to origin_url. -# Useful when an origin routes sites by Host but the backend DNS name differs. -# origin_host_header = "test-publisher.com" proxy_secret = "change-me-proxy-secret" [edge_cookie] From 9fd838c6feb4d3a03ba20f52ee18f4f8559180b0 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 20 May 2026 17:01:49 -0500 Subject: [PATCH 3/4] Add S3 auth and Fastly IO asset routes --- .../src/platform.rs | 173 +++- .../src/asset_image_optimizer.rs | 432 +++++++++ .../src/auction/provider.rs | 2 +- crates/trusted-server-core/src/backend.rs | 2 +- crates/trusted-server-core/src/consent/gpp.rs | 2 +- crates/trusted-server-core/src/consent/tcf.rs | 2 +- .../trusted-server-core/src/consent/types.rs | 1 - crates/trusted-server-core/src/cookies.rs | 2 +- crates/trusted-server-core/src/creative.rs | 2 +- .../src/integrations/datadome.rs | 2 +- .../src/integrations/registry.rs | 4 +- crates/trusted-server-core/src/lib.rs | 6 +- .../trusted-server-core/src/platform/http.rs | 144 +++ .../trusted-server-core/src/platform/mod.rs | 5 +- .../src/platform/test_support.rs | 44 +- crates/trusted-server-core/src/proxy.rs | 299 ++++++- crates/trusted-server-core/src/publisher.rs | 4 +- crates/trusted-server-core/src/redacted.rs | 4 +- crates/trusted-server-core/src/s3_sigv4.rs | 348 ++++++++ crates/trusted-server-core/src/settings.rs | 840 ++++++++++++++++++ .../src/streaming_processor.rs | 2 +- crates/trusted-server-core/src/tsjs.rs | 2 +- docs/.vitepress/config.mts | 1 + docs/guide/asset-routes.md | 201 +++++ docs/guide/configuration.md | 182 +++- docs/guide/first-party-proxy.md | 16 +- ...26-05-19-asset-s3-auth-fastly-io-design.md | 488 ++++++++++ trusted-server.toml | 51 ++ 28 files changed, 3205 insertions(+), 56 deletions(-) create mode 100644 crates/trusted-server-core/src/asset_image_optimizer.rs create mode 100644 crates/trusted-server-core/src/s3_sigv4.rs create mode 100644 docs/guide/asset-routes.md create mode 100644 docs/superpowers/specs/2026-05-19-asset-s3-auth-fastly-io-design.md diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index dd1f098b..e9d3f239 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -19,9 +19,10 @@ use trusted_server_core::geo::geo_from_fastly; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, - StoreName, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, + PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, + PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -176,6 +177,127 @@ impl PlatformBackend for FastlyPlatformBackend { // FastlyPlatformHttpClient — helpers // --------------------------------------------------------------------------- +fn fastly_image_optimizer_region( + region: &str, +) -> Result> { + use fastly::image_optimizer::ImageOptimizerRegion; + + match region + .trim() + .to_ascii_lowercase() + .replace('-', "_") + .as_str() + { + "us_east" | "us_east_1" => Ok(ImageOptimizerRegion::UsEast), + "us_central" | "us_central_1" => Ok(ImageOptimizerRegion::UsCentral), + "us_west" | "us_west_1" | "us_west_2" => Ok(ImageOptimizerRegion::UsWest), + "eu_central" | "eu_central_1" => Ok(ImageOptimizerRegion::EuCentral), + "eu_west" | "eu_west_1" => Ok(ImageOptimizerRegion::EuWest), + "asia" => Ok(ImageOptimizerRegion::Asia), + "australia" => Ok(ImageOptimizerRegion::Australia), + other => Err(Report::new(PlatformError::HttpClient) + .attach(format!("unsupported Image Optimizer region: {other}"))), + } +} + +fn fastly_image_optimizer_format( + format: &str, +) -> Result> { + use fastly::image_optimizer::Format; + + match format.trim().to_ascii_lowercase().as_str() { + "auto" => Ok(Format::Auto), + "avif" => Ok(Format::AVIF), + "gif" => Ok(Format::GIF), + "jpeg" | "jpg" => Ok(Format::JPEG), + "jxl" | "jpegxl" => Ok(Format::JPEGXL), + "mp4" => Ok(Format::MP4), + "png" => Ok(Format::PNG), + "webp" => Ok(Format::WebP), + other => Err(Report::new(PlatformError::HttpClient) + .attach(format!("unsupported Image Optimizer format: {other}"))), + } +} + +fn fastly_resize_filter( + resize_filter: &str, +) -> Result> { + use fastly::image_optimizer::ResizeAlgorithm; + + match resize_filter.trim().to_ascii_lowercase().as_str() { + "nearest" => Ok(ResizeAlgorithm::Nearest), + "bilinear" => Ok(ResizeAlgorithm::Bilinear), + "bicubic" => Ok(ResizeAlgorithm::Bicubic), + "lanczos2" => Ok(ResizeAlgorithm::Lanczos2), + "lanczos3" => Ok(ResizeAlgorithm::Lanczos3), + other => Err(Report::new(PlatformError::HttpClient).attach(format!( + "unsupported Image Optimizer resize filter: {other}" + ))), + } +} + +fn fastly_crop(crop: &PlatformImageOptimizerCrop) -> fastly::image_optimizer::Crop { + use fastly::image_optimizer::{Area, Crop, CropMode, PointOrOffset, Position}; + + let position = match (crop.offset_x, crop.offset_y) { + (Some(x), Some(y)) => Some(Position { + x: Some(PointOrOffset::Offset(x)), + y: Some(PointOrOffset::Offset(y)), + }), + _ => None, + }; + let mode = crop + .mode + .map(|PlatformImageOptimizerCropMode::Smart| CropMode::Smart); + + Crop { + size: Area::AspectRatio((crop.width, crop.height)), + position, + mode, + } +} + +fn apply_fastly_image_optimizer_params( + target: &mut fastly::image_optimizer::ImageOptimizerOptions, + params: PlatformImageOptimizerParams, +) -> Result<(), Report> { + use fastly::image_optimizer::PixelsOrPercentage; + + if let Some(format) = params.format { + target.format = Some(fastly_image_optimizer_format(&format)?); + } + if let Some(quality) = params.quality { + target.quality = Some(quality); + } + if let Some(resize_filter) = params.resize_filter { + target.resize_filter = Some(fastly_resize_filter(&resize_filter)?); + } + if let Some(width) = params.width { + target.width = Some(PixelsOrPercentage::Pixels(width)); + } + if let Some(height) = params.height { + target.height = Some(PixelsOrPercentage::Pixels(height)); + } + if let Some(crop) = params.crop { + target.crop = Some(fastly_crop(&crop)); + } + + Ok(()) +} + +fn apply_fastly_image_optimizer( + req: &mut fastly::Request, + options: PlatformImageOptimizerOptions, +) -> Result<(), Report> { + let region = fastly_image_optimizer_region(&options.region)?; + let mut fastly_options = fastly::image_optimizer::ImageOptimizerOptions::from_region(region); + fastly_options.preserve_query_string_on_origin_request = + Some(options.preserve_query_string_on_origin_request); + apply_fastly_image_optimizer_params(&mut fastly_options, options.params)?; + req.set_image_optimizer(fastly_options); + Ok(()) +} + /// Convert a platform-neutral [`edgezero_core::http::Request`] to a [`fastly::Request`]. /// /// Only buffered `Body::Once` bodies are supported on this path. @@ -228,11 +350,13 @@ fn fastly_response_to_platform( /// Fastly implementation of [`PlatformHttpClient`]. /// -/// - [`send`](PlatformHttpClient::send) — converts the platform request to a -/// `fastly::Request`, calls `.send()`, and wraps the response. -/// - [`send_async`](PlatformHttpClient::send_async) — same conversion but -/// calls `.send_async()` and wraps the `fastly::PendingRequest`. -/// - [`select`](PlatformHttpClient::select) — downcasts each +/// - [`send`](PlatformHttpClient::send) converts the platform request to a +/// `fastly::Request`, applies Image Optimizer metadata when present, calls +/// `.send()`, and wraps the response. +/// - [`send_async`](PlatformHttpClient::send_async) converts the request and +/// calls `.send_async()`. It rejects Image Optimizer metadata because Fastly's +/// async request path does not expose the IO attachment used by asset routes. +/// - [`select`](PlatformHttpClient::select) downcasts each /// [`PlatformPendingRequest`] back to `fastly::PendingRequest` and calls /// `fastly::http::request::select()`. pub struct FastlyPlatformHttpClient; @@ -244,7 +368,11 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { request: PlatformHttpRequest, ) -> Result> { let backend_name = request.backend_name.clone(); - let fastly_req = edge_request_to_fastly(request.request)?; + let image_optimizer = request.image_optimizer; + let mut fastly_req = edge_request_to_fastly(request.request)?; + if let Some(options) = image_optimizer { + apply_fastly_image_optimizer(&mut fastly_req, options)?; + } let fastly_resp = fastly_req .send(&backend_name) .change_context(PlatformError::HttpClient)?; @@ -256,6 +384,10 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { request: PlatformHttpRequest, ) -> Result> { let backend_name = request.backend_name.clone(); + if request.image_optimizer.is_some() { + return Err(Report::new(PlatformError::HttpClient) + .attach("Image Optimizer is not supported with Fastly send_async")); + } let fastly_req = edge_request_to_fastly(request.request)?; let pending = fastly_req .send_async(&backend_name) @@ -610,6 +742,29 @@ mod tests { ); } + #[test] + fn fastly_platform_http_client_send_async_rejects_image_optimizer_metadata() { + let client = FastlyPlatformHttpClient; + let request = request_builder() + .method("GET") + .uri("https://example.com/image.jpg") + .body(Body::empty()) + .expect("should build test request"); + let platform_request = PlatformHttpRequest::new(request, "nonexistent-backend") + .with_image_optimizer(PlatformImageOptimizerOptions::new( + "us_east", + PlatformImageOptimizerParams::default(), + )); + + let err = futures::executor::block_on(client.send_async(platform_request)) + .expect_err("should reject async Image Optimizer requests"); + + assert!( + format!("{err:?}").contains("Image Optimizer"), + "should explain unsupported async IO path: {err:?}" + ); + } + #[test] fn fastly_platform_http_client_send_async_returns_error_for_streaming_body() { let client = FastlyPlatformHttpClient; diff --git a/crates/trusted-server-core/src/asset_image_optimizer.rs b/crates/trusted-server-core/src/asset_image_optimizer.rs new file mode 100644 index 00000000..0524f19c --- /dev/null +++ b/crates/trusted-server-core/src/asset_image_optimizer.rs @@ -0,0 +1,432 @@ +//! Platform-neutral Image Optimizer profile-table handling for asset routes. +//! +//! Asset routes accept small, publisher-defined query controls such as +//! `profile`, `ar`, `x`, and `y`. This module converts those controls into a +//! closed transformation set before the request reaches a platform adapter. It +//! does not pass arbitrary client query parameters through as Image Optimizer +//! options. +//! +//! The profile table supports shared base parameters, per-profile overrides, +//! optional aspect-ratio overrides, crop offset bucketing, and a debug bypass +//! parameter that disables image optimization for a single request. + +use std::borrow::Cow; +use std::collections::HashSet; + +use error_stack::Report; +use url::form_urlencoded; + +use crate::error::TrustedServerError; +use crate::platform::{ + PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, + PlatformImageOptimizerParams, +}; +use crate::settings::{ + ImageOptimizerCropOffsetsConfig, ImageOptimizerProfileSet, MissingCropOffsetMode, + OriginQueryPolicy, ProxyAssetRoute, Settings, UnknownProfilePolicy, +}; + +/// Build Image Optimizer metadata for a route and request query. +/// +/// The incoming query is read only as profile-table input. The asset proxy +/// applies the route origin-query policy separately before signing or sending +/// the upstream request. +/// +/// Returns `Ok(None)` when the route has no enabled IO config or when the +/// configured debug parameter disables IO for this request. +/// +/// # Errors +/// +/// Returns a proxy/configuration error if the configured profile set is missing, +/// a profile parameter cannot be parsed, or the request references an unknown +/// profile in `reject` mode. +pub(crate) fn options_for_asset_request( + settings: &Settings, + route: &ProxyAssetRoute, + query: &str, +) -> Result, Report> { + let Some(route_config) = &route.image_optimizer else { + return Ok(None); + }; + if !route_config.enabled { + return Ok(None); + } + + let profile_set = settings + .image_optimizer + .profile_sets + .get(&route_config.profile_set) + .ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes prefix `{}` references unknown image_optimizer profile_set `{}`", + route.prefix, route_config.profile_set + ), + }) + })?; + + if query_param_value(query, &profile_set.debug_param).as_deref() == Some("1") { + return Ok(None); + } + + let requested_profile = query_param_value(query, &profile_set.profile_param); + let selected_profile = select_profile(profile_set, requested_profile.as_deref())?; + let mut params = parse_param_string(&profile_set.base_params)?; + let profile_params = profile_set + .profiles + .get(selected_profile) + .expect("should select only configured image optimizer profiles"); + params.merge_from(parse_param_string(profile_params)?); + + apply_aspect_ratio_override(profile_set, selected_profile, query, &mut params); + apply_crop_offsets(profile_set, query, &mut params); + + Ok(Some( + PlatformImageOptimizerOptions::new(route_config.region.clone(), params) + .with_preserve_query_string_on_origin_request( + route.origin_query_policy() == OriginQueryPolicy::Preserve, + ), + )) +} + +fn select_profile<'a>( + profile_set: &'a ImageOptimizerProfileSet, + requested_profile: Option<&str>, +) -> Result<&'a str, Report> { + let Some(requested_profile) = requested_profile.filter(|value| !value.is_empty()) else { + return Ok(&profile_set.default_profile); + }; + + if let Some((configured_profile, _)) = profile_set.profiles.get_key_value(requested_profile) { + return Ok(configured_profile.as_str()); + } + + match profile_set.unknown_profile { + UnknownProfilePolicy::UseDefault => Ok(&profile_set.default_profile), + UnknownProfilePolicy::Reject => Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown image profile: {requested_profile}"), + })), + } +} + +fn query_param_value(query: &str, param_name: &str) -> Option { + form_urlencoded::parse(query.as_bytes()) + .find(|(key, _)| key.as_ref() == param_name) + .map(|(_, value)| value.into_owned()) +} + +fn apply_aspect_ratio_override( + profile_set: &ImageOptimizerProfileSet, + selected_profile: &str, + query: &str, + params: &mut PlatformImageOptimizerParams, +) { + let Some(aspect_config) = &profile_set.aspect_ratios else { + return; + }; + let Some(aspect_ratio) = query_param_value(query, &profile_set.aspect_ratio_param) else { + return; + }; + + if !aspect_config + .profiles + .iter() + .any(|profile| profile == selected_profile) + { + return; + } + if !aspect_config + .allowed + .iter() + .any(|allowed| allowed == &aspect_ratio) + { + return; + } + let Some((width, height)) = parse_aspect_ratio_value(&aspect_ratio) else { + return; + }; + + params.crop = Some(PlatformImageOptimizerCrop::aspect_ratio(width, height)); +} + +fn apply_crop_offsets( + profile_set: &ImageOptimizerProfileSet, + query: &str, + params: &mut PlatformImageOptimizerParams, +) { + let Some(offset_config) = &profile_set.crop_offsets else { + return; + }; + if !offset_config.enabled { + return; + } + let Some(crop) = &mut params.crop else { + return; + }; + if !crop.is_bare_aspect_ratio() { + return; + } + + let x = query_param_value(query, &offset_config.x_param); + let y = query_param_value(query, &offset_config.y_param); + if x.is_none() && y.is_none() { + if offset_config.when_missing == MissingCropOffsetMode::Smart { + crop.mode = Some(PlatformImageOptimizerCropMode::Smart); + } + return; + } + + crop.offset_x = Some(normalize_offset(x.as_deref(), offset_config)); + crop.offset_y = Some(normalize_offset(y.as_deref(), offset_config)); +} + +fn normalize_offset(value: Option<&str>, config: &ImageOptimizerCropOffsetsConfig) -> u32 { + let Some(raw) = value else { + return config.default; + }; + let Ok(parsed) = raw.parse::() else { + return config.default; + }; + if parsed > 100 { + return config.default; + } + + let Some(first) = config.buckets.first().copied() else { + return config.default; + }; + for window in config.buckets.windows(2) { + let current = window[0]; + let next = window[1]; + let midpoint = current + ((next - current) / 2); + if parsed < midpoint { + return current; + } + } + config.buckets.last().copied().unwrap_or(first) +} + +fn parse_param_string( + params: &str, +) -> Result> { + let mut parsed = PlatformImageOptimizerParams::default(); + if params.trim().is_empty() { + return Ok(parsed); + } + + for (key, value) in form_urlencoded::parse(params.as_bytes()) { + let key = key.as_ref(); + let value = value.as_ref(); + match key { + "format" => parsed.format = Some(parse_format(value)?), + "quality" => parsed.quality = Some(parse_bounded_u32("quality", value, 0, 100)?), + "resize-filter" => parsed.resize_filter = Some(parse_resize_filter(value)?), + "width" => parsed.width = Some(parse_positive_u32("width", value)?), + "height" => parsed.height = Some(parse_positive_u32("height", value)?), + "crop" => parsed.crop = Some(parse_crop(value)?), + unsupported => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "unsupported image optimizer profile parameter `{unsupported}`" + ), + })); + } + } + } + + Ok(parsed) +} + +fn parse_format(value: &str) -> Result> { + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "auto" | "avif" | "gif" | "jpeg" | "jpg" | "jxl" | "jpegxl" | "mp4" | "png" | "webp" => { + Ok(normalized) + } + _ => Err(Report::new(TrustedServerError::Configuration { + message: format!("unsupported image optimizer format `{value}`"), + })), + } +} + +fn parse_resize_filter(value: &str) -> Result> { + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "nearest" | "bilinear" | "bicubic" | "lanczos2" | "lanczos3" => Ok(normalized), + _ => Err(Report::new(TrustedServerError::Configuration { + message: format!("unsupported image optimizer resize-filter `{value}`"), + })), + } +} + +fn parse_positive_u32(name: &str, value: &str) -> Result> { + let parsed = value.parse::().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("image optimizer `{name}` must be an integer: {err}"), + }) + })?; + if parsed == 0 { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("image optimizer `{name}` must be greater than zero"), + })); + } + Ok(parsed) +} + +fn parse_bounded_u32( + name: &str, + value: &str, + min: u32, + max: u32, +) -> Result> { + let parsed = value.parse::().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("image optimizer `{name}` must be an integer: {err}"), + }) + })?; + if parsed < min || parsed > max { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("image optimizer `{name}` must be in {min}..={max}"), + })); + } + Ok(parsed) +} + +fn parse_crop(value: &str) -> Result> { + let mut parts = value.split(','); + let ratio = parts.next().unwrap_or_default(); + let (width, height) = parse_crop_ratio(ratio)?; + let mut crop = PlatformImageOptimizerCrop::aspect_ratio(width, height); + let mut seen_suffixes = HashSet::new(); + + for suffix in parts { + if suffix == "smart" { + crop.mode = Some(PlatformImageOptimizerCropMode::Smart); + seen_suffixes.insert(Cow::Borrowed("smart")); + continue; + } + if let Some(offset) = suffix.strip_prefix("offset-x") { + crop.offset_x = Some(parse_bounded_u32("crop offset-x", offset, 0, 100)?); + seen_suffixes.insert(Cow::Borrowed("offset-x")); + continue; + } + if let Some(offset) = suffix.strip_prefix("offset-y") { + crop.offset_y = Some(parse_bounded_u32("crop offset-y", offset, 0, 100)?); + seen_suffixes.insert(Cow::Borrowed("offset-y")); + continue; + } + return Err(Report::new(TrustedServerError::Configuration { + message: format!("unsupported image optimizer crop suffix `{suffix}`"), + })); + } + + if crop.mode.is_some() && (crop.offset_x.is_some() || crop.offset_y.is_some()) { + return Err(Report::new(TrustedServerError::Configuration { + message: "image optimizer crop cannot combine smart mode with explicit offsets" + .to_string(), + })); + } + if seen_suffixes.contains(&Cow::Borrowed("offset-x")) + != seen_suffixes.contains(&Cow::Borrowed("offset-y")) + { + return Err(Report::new(TrustedServerError::Configuration { + message: "image optimizer crop offsets must include both offset-x and offset-y" + .to_string(), + })); + } + + Ok(crop) +} + +fn parse_crop_ratio(value: &str) -> Result<(u32, u32), Report> { + let (width, height) = value.split_once(':').ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!("image optimizer crop `{value}` must look like `width:height`"), + }) + })?; + let width = parse_positive_u32("crop width", width)?; + let height = parse_positive_u32("crop height", height)?; + Ok((width, height)) +} + +fn parse_aspect_ratio_value(value: &str) -> Option<(u32, u32)> { + let (width, height) = value.split_once('-')?; + let width = width.parse::().ok()?; + let height = height.parse::().ok()?; + if width == 0 || height == 0 { + return None; + } + Some((width, height)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{ + ImageOptimizerAspectRatioConfig, ImageOptimizerCropOffsetsConfig, ImageOptimizerProfileSet, + }; + + fn profile_set() -> ImageOptimizerProfileSet { + let mut profiles = std::collections::HashMap::new(); + profiles.insert("default".to_string(), "width=1920".to_string()); + profiles.insert("medium".to_string(), "format=auto&width=828".to_string()); + ImageOptimizerProfileSet { + base_params: "quality=70&resize-filter=bicubic".to_string(), + default_profile: "default".to_string(), + unknown_profile: UnknownProfilePolicy::UseDefault, + profile_param: "profile".to_string(), + aspect_ratio_param: "ar".to_string(), + debug_param: "_io_debug".to_string(), + profiles, + aspect_ratios: Some(ImageOptimizerAspectRatioConfig { + allowed: vec!["1-1".to_string(), "16-9".to_string()], + profiles: vec!["medium".to_string()], + }), + crop_offsets: Some(ImageOptimizerCropOffsetsConfig { + enabled: true, + x_param: "x".to_string(), + y_param: "y".to_string(), + buckets: vec![10, 30, 50, 70, 90], + default: 50, + when_missing: MissingCropOffsetMode::Smart, + }), + } + } + + #[test] + fn profile_conversion_adds_aspect_ratio_and_smart_crop() { + let set = profile_set(); + let selected = select_profile(&set, Some("medium")).expect("should select profile"); + let mut params = parse_param_string(&set.base_params).expect("should parse base params"); + params.merge_from( + parse_param_string(set.profiles.get(selected).expect("should get profile")) + .expect("should parse profile params"), + ); + apply_aspect_ratio_override(&set, selected, "profile=medium&ar=1-1", &mut params); + apply_crop_offsets(&set, "profile=medium&ar=1-1", &mut params); + + assert_eq!(params.quality, Some(70)); + assert_eq!(params.resize_filter.as_deref(), Some("bicubic")); + assert_eq!(params.format.as_deref(), Some("auto")); + assert_eq!(params.width, Some(828)); + let crop = params.crop.expect("should add crop"); + assert_eq!((crop.width, crop.height), (1, 1)); + assert_eq!(crop.mode, Some(PlatformImageOptimizerCropMode::Smart)); + } + + #[test] + fn profile_conversion_buckets_offsets() { + let set = profile_set(); + let selected = select_profile(&set, Some("medium")).expect("should select profile"); + let mut params = parse_param_string("width=828").expect("should parse params"); + apply_aspect_ratio_override( + &set, + selected, + "profile=medium&ar=16-9&x=79&y=bad", + &mut params, + ); + apply_crop_offsets(&set, "profile=medium&ar=16-9&x=79&y=bad", &mut params); + + let crop = params.crop.expect("should add crop"); + assert_eq!((crop.offset_x, crop.offset_y), (Some(70), Some(50))); + } +} diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index cd3fcfc3..390cf30b 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -62,7 +62,7 @@ pub trait AuctionProvider: Send + Sync { /// /// `timeout_ms` is the effective timeout that will be used when the backend /// is registered in [`request_bids`](Self::request_bids). It must be - /// forwarded to [`BackendConfig::backend_name_for_url()`] so the predicted + /// forwarded to [`crate::backend::BackendConfig::backend_name_for_url()`] so the predicted /// name matches the actual registration (the timeout is part of the name). fn backend_name(&self, _timeout_ms: u32) -> Option { None diff --git a/crates/trusted-server-core/src/backend.rs b/crates/trusted-server-core/src/backend.rs index 468a3f83..c69a989c 100644 --- a/crates/trusted-server-core/src/backend.rs +++ b/crates/trusted-server-core/src/backend.rs @@ -138,7 +138,7 @@ impl<'a> BackendConfig<'a> { /// Return the deterministic backend name without registering anything. /// - /// Convenience wrapper over [`Self::compute_name`] that discards the + /// Convenience wrapper over `Self::compute_name` that discards the /// resolved port, used by [`crate::platform::PlatformBackend`] /// implementations that only need the name for correlation. /// diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs index 9d0e5c81..c4a77e9a 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -44,7 +44,7 @@ const MAX_GPP_STRING_LEN: usize = 8192; /// # Errors /// /// - [`ConsentDecodeError::InvalidGppString`] if the string exceeds -/// [`MAX_GPP_STRING_LEN`] or the `iab_gpp` parser fails. +/// `MAX_GPP_STRING_LEN` or the `iab_gpp` parser fails. pub fn decode_gpp_string(gpp_string: &str) -> Result> { if gpp_string.len() > MAX_GPP_STRING_LEN { return Err(Report::new(ConsentDecodeError::InvalidGppString { diff --git a/crates/trusted-server-core/src/consent/tcf.rs b/crates/trusted-server-core/src/consent/tcf.rs index f989e5e9..9261b514 100644 --- a/crates/trusted-server-core/src/consent/tcf.rs +++ b/crates/trusted-server-core/src/consent/tcf.rs @@ -67,7 +67,7 @@ const MAX_VENDOR_ID: u16 = 10_000; /// # Errors /// /// - [`ConsentDecodeError::InvalidTcString`] if the string exceeds -/// [`MAX_TC_STRING_LEN`], base64 decoding fails, the version is not 2, or +/// `MAX_TC_STRING_LEN`, base64 decoding fails, the version is not 2, or /// the bitfield is too short. pub fn decode_tc_string(tc_string: &str) -> Result> { if tc_string.len() > MAX_TC_STRING_LEN { diff --git a/crates/trusted-server-core/src/consent/types.rs b/crates/trusted-server-core/src/consent/types.rs index a68eda9a..5dcf5771 100644 --- a/crates/trusted-server-core/src/consent/types.rs +++ b/crates/trusted-server-core/src/consent/types.rs @@ -7,7 +7,6 @@ //! - [`UsPrivacy`] / [`PrivacyFlag`] — decoded US Privacy (CCPA) 4-char string //! - [`TcfConsent`] — decoded TCF v2 core consent data //! - [`GppConsent`] — decoded GPP consent data -//! - [`Jurisdiction`] — the privacy regime applicable to the request //! - [`ConsentSource`] — how consent was sourced (cookie, KV store, etc.) use core::fmt; diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index 67f4a4c7..63ae63b8 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -273,7 +273,7 @@ pub(crate) fn try_build_ec_cookie_value(settings: &Settings, ec_id: &str) -> Opt /// # Panics /// /// Does not panic in practice — the cookie value is validated by -/// [`ec_cookie_value_is_safe`] (early return if invalid) before +/// `ec_cookie_value_is_safe` (early return if invalid) before /// [`http::HeaderValue::from_str`] is called, so the expect is unreachable. /// Listed here only because clippy cannot prove it statically. pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs index 17727e61..e6c897e3 100644 --- a/crates/trusted-server-core/src/creative.rs +++ b/crates/trusted-server-core/src/creative.rs @@ -342,7 +342,7 @@ fn is_safe_data_uri(lower: &str) -> bool { /// This runs as the first pass in the creative pipeline, before URL rewriting, so the /// rewriter only ever sees clean markup. /// -/// Inputs larger than [`MAX_CREATIVE_SIZE`] are rejected (empty string returned) with a warning. +/// Inputs larger than `MAX_CREATIVE_SIZE` are rejected (empty string returned) with a warning. /// On parse errors the markup is also rejected (empty string returned) with a warning. #[must_use] pub fn sanitize_creative_html(markup: &str) -> String { diff --git a/crates/trusted-server-core/src/integrations/datadome.rs b/crates/trusted-server-core/src/integrations/datadome.rs index 0ce83b2f..79c34c83 100644 --- a/crates/trusted-server-core/src/integrations/datadome.rs +++ b/crates/trusted-server-core/src/integrations/datadome.rs @@ -42,7 +42,7 @@ //! //! 1. **SDK Loading**: Browser requests `/integrations/datadome/tags.js` //! 2. **Proxy & Rewrite**: Trusted Server fetches from `js.datadome.co`, rewrites internal -//! URLs to first-party paths using [`DATADOME_URL_PATTERN`] +//! URLs to first-party paths using `DATADOME_URL_PATTERN` //! 3. **Signal Collection**: SDK sends signals to `/integrations/datadome/js/` //! 4. **Transparent Proxy**: Trusted Server forwards to `api-js.datadome.co`, returns response //! diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 924452b0..8a24d82f 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -248,8 +248,8 @@ impl IntegrationEndpoint { /// `Send + Sync` bounds are required so trait objects can be stored in /// `Arc` and shared across the single-threaded WASM /// request context. The `?Send` on the async methods is intentional — see the -/// `!Send` design rationale on [`PlatformPendingRequest`] for the full -/// explanation. On wasm32 these bounds are compatible because the runtime is +/// `!Send` design rationale on [`crate::platform::PlatformPendingRequest`] for +/// the full explanation. On wasm32 these bounds are compatible because the runtime is /// single-threaded. #[async_trait(?Send)] pub trait IntegrationProxy: Send + Sync { diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index e1af33b7..9ff2f46f 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -12,13 +12,11 @@ //! - [`consent`]: Consent signal extraction and logging //! - [`geo`]: Geographic location utilities and DMA code extraction //! - [`models`]: Data models for ad serving and callbacks -//! - [`prebid`]: Prebid integration and real-time bidding support -//! - [`privacy`]: Privacy utilities and helpers +//! - [`integrations::prebid`]: Prebid integration and real-time bidding support //! - [`settings`]: Configuration management and validation //! - [`streaming_replacer`]: Streaming URL replacement for large responses //! - [`edge_cookie`]: Edge Cookie (EC) ID generation using HMAC //! - [`test_support`]: Testing utilities and mocks -//! - [`why`]: Debugging and introspection utilities #![cfg_attr( test, @@ -31,6 +29,7 @@ ) )] +pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; pub mod auth; @@ -57,6 +56,7 @@ pub mod publisher; pub mod redacted; pub mod request_signing; pub mod rsc_flight; +pub(crate) mod s3_sigv4; pub mod settings; pub mod settings_data; pub mod storage; diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index b6efe1b4..4a66ff06 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -6,6 +6,133 @@ use error_stack::Report; use super::PlatformError; +/// Platform-neutral Image Optimizer options for an upstream request. +/// +/// Core code stores only a closed transformation set here. The Fastly adapter is +/// responsible for translating these values to SDK-specific +/// `ImageOptimizerOptions`, while non-Fastly adapters can reject or ignore the +/// metadata according to their platform capabilities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformImageOptimizerOptions { + /// Image Optimizer processing region understood by the target adapter. + pub region: String, + /// Whether non-IO query parameters should be preserved on the origin request. + pub preserve_query_string_on_origin_request: bool, + /// Transformation parameters to apply. + pub params: PlatformImageOptimizerParams, +} + +impl PlatformImageOptimizerOptions { + /// Create Image Optimizer options for the given region and params. + #[must_use] + pub fn new(region: impl Into, params: PlatformImageOptimizerParams) -> Self { + Self { + region: region.into(), + preserve_query_string_on_origin_request: false, + params, + } + } + + /// Preserve non-IO query parameters on the origin request. + /// + /// Asset routes with profile-table IO reject arbitrary query preservation by + /// default because client query parameters can otherwise become additional + /// Image Optimizer inputs. + #[must_use] + pub fn with_preserve_query_string_on_origin_request(mut self, preserve: bool) -> Self { + self.preserve_query_string_on_origin_request = preserve; + self + } +} + +/// Platform-neutral subset of image transformation parameters. +/// +/// This intentionally mirrors only the parameters accepted by asset-route +/// profile tables: format, quality, resize filter, dimensions, and crop. Client +/// query strings are converted into this closed set before the request reaches a +/// platform adapter. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PlatformImageOptimizerParams { + /// Output format such as `auto` or `webp`. + pub format: Option, + /// Output quality from 0 to 100. + pub quality: Option, + /// Resize filter such as `bicubic`. + pub resize_filter: Option, + /// Output width in pixels. + pub width: Option, + /// Output height in pixels. + pub height: Option, + /// Crop transformation. + pub crop: Option, +} + +impl PlatformImageOptimizerParams { + /// Merge another param set into this one, with `other` taking precedence. + pub fn merge_from(&mut self, other: Self) { + if other.format.is_some() { + self.format = other.format; + } + if other.quality.is_some() { + self.quality = other.quality; + } + if other.resize_filter.is_some() { + self.resize_filter = other.resize_filter; + } + if other.width.is_some() { + self.width = other.width; + } + if other.height.is_some() { + self.height = other.height; + } + if other.crop.is_some() { + self.crop = other.crop; + } + } +} + +/// Platform-neutral crop transformation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformImageOptimizerCrop { + /// Aspect-ratio width component. + pub width: u32, + /// Aspect-ratio height component. + pub height: u32, + /// Optional crop focus mode. + pub mode: Option, + /// Optional x-axis crop offset bucket. + pub offset_x: Option, + /// Optional y-axis crop offset bucket. + pub offset_y: Option, +} + +impl PlatformImageOptimizerCrop { + /// Create a bare aspect-ratio crop. + #[must_use] + pub fn aspect_ratio(width: u32, height: u32) -> Self { + Self { + width, + height, + mode: None, + offset_x: None, + offset_y: None, + } + } + + /// Returns true when no focus mode or explicit offsets have been applied. + #[must_use] + pub fn is_bare_aspect_ratio(&self) -> bool { + self.mode.is_none() && self.offset_x.is_none() && self.offset_y.is_none() + } +} + +/// Platform-neutral crop focus mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlatformImageOptimizerCropMode { + /// Use Fastly IO smart crop mode. + Smart, +} + /// Outbound HTTP request paired with a pre-resolved backend name. /// /// Uses `EdgeZero`'s neutral [`EdgeRequest`] type so adapters share one @@ -17,6 +144,11 @@ pub struct PlatformHttpRequest { pub request: EdgeRequest, /// Backend name resolved ahead of time via `PlatformBackend`. pub backend_name: String, + /// Optional Image Optimizer metadata for platforms that support it. + /// + /// Adapters that cannot attach this metadata to their send path should + /// return an error rather than silently dropping transformations. + pub image_optimizer: Option, } impl PlatformHttpRequest { @@ -26,8 +158,20 @@ impl PlatformHttpRequest { Self { request, backend_name: backend_name.into(), + image_optimizer: None, } } + + /// Attach Image Optimizer metadata to the request. + /// + /// The current Fastly adapter supports this on [`PlatformHttpClient::send`] + /// and rejects it on [`PlatformHttpClient::send_async`] because Fastly IO is + /// not available through the fan-out helper path. + #[must_use] + pub fn with_image_optimizer(mut self, options: PlatformImageOptimizerOptions) -> Self { + self.image_optimizer = Some(options); + self + } } /// Outbound HTTP response with optional backend correlation metadata. diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 6662e837..d41ef1e0 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -42,8 +42,9 @@ mod types; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; pub use error::PlatformError; pub use http::{ - PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, + PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, + PlatformPendingRequest, PlatformResponse, PlatformSelectResult, }; pub use kv::UnavailableKvStore; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index ac9b2bda..2aad3196 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -9,8 +9,9 @@ use rand::rngs::OsRng; use super::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerOptions, + PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + RuntimeServices, StoreId, StoreName, }; use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME}; @@ -215,6 +216,8 @@ pub(crate) struct StubHttpClient { responses: Mutex>, // Headers captured per send call, stored as (name, value) string pairs. request_headers: Mutex>>, + image_optimizer_options: Mutex>>, + request_uris: Mutex>, } struct StubHttpResponse { @@ -229,6 +232,8 @@ impl StubHttpClient { calls: Mutex::new(Vec::new()), responses: Mutex::new(VecDeque::new()), request_headers: Mutex::new(Vec::new()), + image_optimizer_options: Mutex::new(Vec::new()), + request_uris: Mutex::new(Vec::new()), } } @@ -272,6 +277,22 @@ impl StubHttpClient { .expect("should lock request_headers") .clone() } + + /// Return Image Optimizer metadata captured per `send` call, in order. + pub fn recorded_image_optimizer_options(&self) -> Vec> { + self.image_optimizer_options + .lock() + .expect("should lock image optimizer options") + .clone() + } + + /// Return request URIs captured per `send` call, in order. + pub fn recorded_request_uris(&self) -> Vec { + self.request_uris + .lock() + .expect("should lock request URIs") + .clone() + } } // ?Send matches PlatformHttpClient. See http.rs for the full rationale. @@ -286,6 +307,15 @@ impl PlatformHttpClient for StubHttpClient { .expect("should lock calls") .push(request.backend_name.clone()); + self.image_optimizer_options + .lock() + .expect("should lock image optimizer options") + .push(request.image_optimizer.clone()); + self.request_uris + .lock() + .expect("should lock request URIs") + .push(request.request.uri().to_string()); + let headers: Vec<(String, String)> = request .request .headers() @@ -478,10 +508,18 @@ pub(crate) fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { /// Useful for tests that need to verify `services.http_client()` call sites. pub(crate) fn build_services_with_http_client( http_client: Arc, +) -> RuntimeServices { + build_services_with_secret_and_http_client(NoopSecretStore, http_client) +} + +/// Build a [`RuntimeServices`] with a custom secret store, [`StubBackend`], and HTTP client. +pub(crate) fn build_services_with_secret_and_http_client( + secret_store: impl PlatformSecretStore + 'static, + http_client: Arc, ) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(NoopConfigStore)) - .secret_store(Arc::new(NoopSecretStore)) + .secret_store(Arc::new(secret_store)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 6487d5ce..2cae55e3 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -16,9 +16,10 @@ use crate::creative::{CreativeCssProcessor, CreativeHtmlProcessor}; use crate::edge_cookie::get_ec_id; use crate::error::TrustedServerError; use crate::platform::{ - PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, RuntimeServices, + PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, RuntimeServices, StoreName, }; -use crate::settings::{ProxyAssetRoute, Settings}; +use crate::s3_sigv4::{self, S3Credentials}; +use crate::settings::{AssetOriginAuth, OriginQueryPolicy, ProxyAssetRoute, Settings}; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; /// Chunk size used for streaming content through the rewrite pipeline. @@ -584,23 +585,90 @@ fn asset_origin_host_header( }) } +fn apply_asset_origin_auth( + services: &RuntimeServices, + method: &Method, + target_url: &url::Url, + headers: &mut http::HeaderMap, + auth: &AssetOriginAuth, +) -> Result<(), Report> { + match auth { + AssetOriginAuth::S3SigV4(config) => { + let store_name = StoreName::from(config.secret_store.as_str()); + let access_key_id = services + .secret_store() + .get_string(&store_name, &config.access_key_id) + .change_context(TrustedServerError::Proxy { + message: "failed to read S3 access key ID from secret store".to_string(), + })?; + let secret_access_key = services + .secret_store() + .get_string(&store_name, &config.secret_access_key) + .change_context(TrustedServerError::Proxy { + message: "failed to read S3 secret access key from secret store".to_string(), + })?; + let session_token = config + .session_token + .as_deref() + .map(|key| { + services + .secret_store() + .get_string(&store_name, key) + .change_context(TrustedServerError::Proxy { + message: "failed to read S3 session token from secret store" + .to_string(), + }) + }) + .transpose()?; + let credentials = S3Credentials { + access_key_id, + secret_access_key, + session_token, + }; + + s3_sigv4::sign_headers( + method, + target_url, + headers, + &config.region, + &credentials, + SystemTime::now(), + ) + } + } +} + /// Proxy a configured first-party asset path to its matched asset origin. /// /// This is a lean raw pass-through path: it preserves status/body/headers, -/// does not follow redirects, and bypasses publisher-page processing. +/// does not follow redirects, and bypasses publisher-page processing. The flow +/// is path rewrite, profile-table Image Optimizer metadata extraction, optional +/// origin query stripping, optional origin authentication, then platform send. +/// +/// The origin query policy is applied before S3 signing so the signature covers +/// the exact URL sent to the asset origin. Image Optimizer metadata remains +/// separate from the origin URL and is translated by the platform adapter. /// /// # Errors /// /// Returns an error if the configured origin URL is invalid, backend -/// registration fails, or the upstream request cannot be sent. +/// registration fails, S3 credentials cannot be read, signing fails, image +/// optimizer metadata cannot be built, or the upstream request cannot be sent. pub async fn handle_asset_proxy_request( settings: &Settings, services: &RuntimeServices, req: Request, route: &ProxyAssetRoute, ) -> Result> { - let target_url = - build_asset_proxy_target_url(route, req.get_path(), req.get_query_str().unwrap_or(""))?; + let incoming_query = req.get_query_str().unwrap_or(""); + let mut target_url = build_asset_proxy_target_url(route, req.get_path(), incoming_query)?; + let image_optimizer = + crate::asset_image_optimizer::options_for_asset_request(settings, route, incoming_query)?; + + if route.origin_query_policy() == OriginQueryPolicy::Strip { + target_url.set_query(None); + } + let scheme = target_url.scheme(); let host = target_url.host_str().ok_or_else(|| { Report::new(TrustedServerError::Proxy { @@ -621,6 +689,24 @@ pub async fn handle_asset_proxy_request( message: "asset backend registration failed".to_string(), })?; + let mut outbound_headers = http::HeaderMap::new(); + for header_name in ASSET_PROXY_FORWARD_HEADERS { + if let Some(value) = req.get_header(&header_name) { + outbound_headers.insert(header_name, value.clone()); + } + } + outbound_headers.insert(header::HOST, asset_origin_host_header(&target_url)?); + + if let Some(auth) = &route.auth { + apply_asset_origin_auth( + services, + req.get_method(), + &target_url, + &mut outbound_headers, + auth, + )?; + } + let mut builder = edge_request_builder().method(req.get_method().clone()).uri( target_url .as_str() @@ -630,14 +716,6 @@ pub async fn handle_asset_proxy_request( })?, ); - let mut outbound_headers = http::HeaderMap::new(); - for header_name in ASSET_PROXY_FORWARD_HEADERS { - if let Some(value) = req.get_header(&header_name) { - outbound_headers.insert(header_name, value.clone()); - } - } - outbound_headers.insert(header::HOST, asset_origin_host_header(&target_url)?); - for (name, value) in &outbound_headers { builder = builder.header(name, value); } @@ -648,10 +726,14 @@ pub async fn handle_asset_proxy_request( .change_context(TrustedServerError::Proxy { message: "failed to build asset proxy request".to_string(), })?; + let mut platform_req = PlatformHttpRequest::new(edge_req, backend_name); + if let Some(image_optimizer) = image_optimizer { + platform_req = platform_req.with_image_optimizer(image_optimizer); + } let platform_resp = services .http_client() - .send(PlatformHttpRequest::new(edge_req, backend_name)) + .send(platform_req) .await .change_context(TrustedServerError::Proxy { message: "Failed to proxy asset request".to_string(), @@ -1421,6 +1503,7 @@ fn reconstruct_and_validate_signed_target( #[cfg(test)] mod tests { + use std::collections::HashMap; use std::sync::Arc; use super::{ @@ -1434,13 +1517,18 @@ mod tests { use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; use crate::platform::test_support::{ - build_services_with_http_client, noop_services, StubHttpClient, + build_services_with_http_client, build_services_with_secret_and_http_client, noop_services, + HashMapSecretStore, StubHttpClient, }; use crate::platform::{ PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, }; - use crate::settings::ProxyAssetRoute; + use crate::settings::{ + AssetImageOptimizerConfig, AssetOriginAuth, ImageOptimizerAspectRatioConfig, + ImageOptimizerCropOffsetsConfig, ImageOptimizerProfileSet, ImageOptimizerSettings, + OriginQueryPolicy, ProxyAssetRoute, S3SigV4AuthConfig, + }; use crate::test_support::tests::create_test_settings; use bytes::Bytes; use edgezero_core::body::Body as EdgeBody; @@ -2512,6 +2600,183 @@ mod tests { ); } + fn test_profile_set() -> ImageOptimizerProfileSet { + let mut profiles = HashMap::new(); + profiles.insert("default".to_string(), "width=1920".to_string()); + profiles.insert("medium".to_string(), "format=auto&width=828".to_string()); + ImageOptimizerProfileSet { + base_params: "quality=70&resize-filter=bicubic".to_string(), + default_profile: "default".to_string(), + unknown_profile: Default::default(), + profile_param: "profile".to_string(), + aspect_ratio_param: "ar".to_string(), + debug_param: "_io_debug".to_string(), + profiles, + aspect_ratios: Some(ImageOptimizerAspectRatioConfig { + allowed: vec!["1-1".to_string()], + profiles: vec!["medium".to_string()], + }), + crop_offsets: Some(ImageOptimizerCropOffsetsConfig { + enabled: true, + x_param: "x".to_string(), + y_param: "y".to_string(), + buckets: vec![10, 30, 50, 70, 90], + default: 50, + when_missing: Default::default(), + }), + } + } + + #[tokio::test] + async fn handle_asset_proxy_request_signs_s3_and_strips_transform_query() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let mut secrets = HashMap::new(); + secrets.insert( + "access_key_id".to_string(), + b"AKIAIOSFODNN7EXAMPLE".to_vec(), + ); + secrets.insert( + "secret_access_key".to_string(), + b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(), + ); + let services = build_services_with_secret_and_http_client( + HashMapSecretStore::new(secrets), + Arc::clone(&stub) as Arc, + ); + let settings = create_test_settings(); + let req = Request::new( + Method::GET, + "https://www.example.com/.images/foo.jpg?profile=medium&ar=1-1", + ); + let mut route = ProxyAssetRoute::new( + "/.images/", + "https://examplebucket.s3.us-east-1.amazonaws.com", + ); + route.auth = Some(AssetOriginAuth::S3SigV4(S3SigV4AuthConfig { + region: "us-east-1".to_string(), + secret_store: "s3-auth".to_string(), + access_key_id: "access_key_id".to_string(), + secret_access_key: "secret_access_key".to_string(), + session_token: None, + origin_query: Some(OriginQueryPolicy::Strip), + })); + + handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy signed S3 asset request"); + + let uris = stub.recorded_request_uris(); + assert_eq!( + uris, + vec!["https://examplebucket.s3.us-east-1.amazonaws.com/.images/foo.jpg"], + "should strip transform query before signing and sending to S3" + ); + let headers = stub.recorded_request_headers(); + let sent = &headers[0]; + let header_value = |name: &str| -> Option { + sent.iter().find(|(n, _)| n == name).map(|(_, v)| v.clone()) + }; + assert_eq!( + header_value("host").as_deref(), + Some("examplebucket.s3.us-east-1.amazonaws.com"), + "should sign for the S3 origin host" + ); + assert!( + header_value("authorization") + .as_deref() + .is_some_and(|value| value.starts_with("AWS4-HMAC-SHA256 Credential=")), + "should add a SigV4 Authorization header" + ); + assert_eq!( + header_value("x-amz-content-sha256").as_deref(), + Some("UNSIGNED-PAYLOAD"), + "should use unsigned payload for read-only asset requests" + ); + } + + #[tokio::test] + async fn handle_asset_proxy_request_attaches_image_optimizer_metadata() { + 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(); + settings.image_optimizer = ImageOptimizerSettings { + profile_sets: HashMap::from([("default_images".to_string(), test_profile_set())]), + }; + let req = Request::new( + Method::GET, + "https://www.example.com/.images/foo.jpg?profile=medium&ar=1-1&x=71&y=bad", + ); + let mut route = ProxyAssetRoute::new("/.images/", "https://assets.example.com"); + 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 optimized asset request"); + + let uris = stub.recorded_request_uris(); + assert_eq!( + uris, + vec!["https://assets.example.com/.images/foo.jpg"], + "should strip profile-table query from the origin request by default" + ); + let options = stub.recorded_image_optimizer_options(); + let options = options[0] + .as_ref() + .expect("should attach image optimizer metadata"); + assert_eq!(options.region, "us_east"); + assert!(!options.preserve_query_string_on_origin_request); + assert_eq!(options.params.quality, Some(70)); + assert_eq!(options.params.resize_filter.as_deref(), Some("bicubic")); + assert_eq!(options.params.format.as_deref(), Some("auto")); + assert_eq!(options.params.width, Some(828)); + let crop = options.params.crop.as_ref().expect("should set crop"); + assert_eq!((crop.width, crop.height), (1, 1)); + assert_eq!((crop.offset_x, crop.offset_y), (Some(70), Some(50))); + } + + #[tokio::test] + async fn handle_asset_proxy_request_debug_param_disables_image_optimizer() { + 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(); + settings.image_optimizer = ImageOptimizerSettings { + profile_sets: HashMap::from([("default_images".to_string(), test_profile_set())]), + }; + let req = Request::new( + Method::GET, + "https://www.example.com/.images/foo.jpg?profile=medium&_io_debug=1", + ); + let mut route = ProxyAssetRoute::new("/.images/", "https://assets.example.com"); + 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 debug asset request"); + + let options = stub.recorded_image_optimizer_options(); + assert!( + options[0].is_none(), + "debug query param should disable image optimizer metadata" + ); + } + #[tokio::test] async fn proxy_request_returns_error_for_streaming_platform_response_body() { let services = build_services_with_http_client( diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index dfce3733..5da641aa 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4,7 +4,7 @@ //! `fastly::Body`/`Request`/`Response` at its handler boundaries — the entry //! points ([`handle_publisher_request`], [`stream_publisher_body`]) still //! accept and return `fastly::Body` and `fastly::Response`. The streaming -//! processor itself is generic: [`process_response_streaming`] writes into +//! processor itself is generic: `process_response_streaming` writes into //! any [`Write`] (a `Vec` for buffered routes, a `StreamingBody` for the //! streaming route). The HTTP-type coupling will be addressed in the //! platform HTTP-type migration alongside all other @@ -388,7 +388,7 @@ pub(crate) fn classify_response_route( ResponseRoute::Stream } -/// Owned version of [`ProcessResponseParams`] for returning from +/// Owned version of `ProcessResponseParams` for returning from /// `handle_publisher_request` without lifetime issues. pub struct OwnedProcessResponseParams { pub(crate) content_encoding: String, diff --git a/crates/trusted-server-core/src/redacted.rs b/crates/trusted-server-core/src/redacted.rs index 7193a00a..8b1d05bb 100644 --- a/crates/trusted-server-core/src/redacted.rs +++ b/crates/trusted-server-core/src/redacted.rs @@ -1,7 +1,7 @@ // NOTE: This file is also included in build.rs via #[path]. // It must remain self-contained (no `crate::` imports). -//! A wrapper type that redacts sensitive values in [`Debug`] and [`Display`] output. +//! A wrapper type that redacts sensitive values in [`Debug`] and [`fmt::Display`] output. //! //! Use [`Redacted`] for secrets, passwords, API keys, and other sensitive values //! that must never appear in logs or error messages. @@ -10,7 +10,7 @@ use core::fmt; use serde::{Deserialize, Serialize}; -/// Wraps a value so that [`Debug`] and [`Display`] print `[REDACTED]` +/// Wraps a value so that [`Debug`] and [`fmt::Display`] print `[REDACTED]` /// instead of the inner contents. /// /// Access the real value via [`expose`](Redacted::expose). Callers must diff --git a/crates/trusted-server-core/src/s3_sigv4.rs b/crates/trusted-server-core/src/s3_sigv4.rs new file mode 100644 index 00000000..122e103d --- /dev/null +++ b/crates/trusted-server-core/src/s3_sigv4.rs @@ -0,0 +1,348 @@ +//! Minimal AWS Signature Version 4 signing for `S3` asset requests. +//! +//! Asset routes use this module for read-only `S3` origins. The signer emits +//! header-based `SigV4` authentication with `UNSIGNED-PAYLOAD`, so it does not +//! need `AWS` SDK credential providers, request-body hashing, or presigned query +//! parameters. The caller must provide the final origin URL and `Host` header +//! after any path rewrite or query-strip policy has run. +//! +//! Canonicalization follows the URL that will be sent to `S3`. Existing percent +//! escapes in path and query components are preserved and normalized to upper +//! case, while raw reserved bytes are encoded using `AWS` percent-encoding rules. + +use std::time::SystemTime; + +use chrono::{DateTime, Utc}; +use error_stack::Report; +use hmac::{Hmac, Mac}; +use http::{header, HeaderMap, HeaderValue, Method}; +use sha2::{Digest as _, Sha256}; +use url::Url; + +use crate::error::TrustedServerError; + +type HmacSha256 = Hmac; + +const ALGORITHM: &str = "AWS4-HMAC-SHA256"; +const SERVICE: &str = "s3"; +const TERMINATOR: &str = "aws4_request"; +const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; + +/// `AWS` credentials used to sign an `S3` request. +/// +/// Values are loaded from the configured runtime secret store immediately before +/// signing. Temporary credentials can include a session token, which becomes the +/// signed `x-amz-security-token` header. +#[derive(Debug, Clone)] +pub struct S3Credentials { + /// `AWS` access key ID. + pub access_key_id: String, + /// `AWS` secret access key. + pub secret_access_key: String, + /// Optional `AWS` session token for temporary credentials. + pub session_token: Option, +} + +/// Sign an `S3` request header map using AWS Signature Version 4. +/// +/// The request URL and header map must already reflect the final origin request +/// that `S3` will receive, including path, query, and `Host` header. Existing +/// `Authorization` and `x-amz-*` signing headers are replaced so forwarded +/// client headers cannot influence the signature. +/// +/// This signer is scoped to read-only asset proxying. It always signs with +/// `x-amz-content-sha256: UNSIGNED-PAYLOAD`, which is valid for `GET` and +/// `HEAD` object reads and avoids buffering or hashing a request body. +/// +/// # Errors +/// +/// Returns a proxy error when a required signing header is missing or a signing +/// header value is invalid. +pub fn sign_headers( + method: &Method, + url: &Url, + headers: &mut HeaderMap, + region: &str, + credentials: &S3Credentials, + now: SystemTime, +) -> Result<(), Report> { + let datetime = DateTime::::from(now); + let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = datetime.format("%Y%m%d").to_string(); + + headers.remove(header::AUTHORIZATION); + headers.remove("x-amz-date"); + headers.remove("x-amz-content-sha256"); + headers.remove("x-amz-security-token"); + + headers.insert( + "x-amz-date", + HeaderValue::from_str(&amz_date).change_context_invalid_header("x-amz-date")?, + ); + headers.insert( + "x-amz-content-sha256", + HeaderValue::from_static(UNSIGNED_PAYLOAD), + ); + if let Some(token) = credentials + .session_token + .as_deref() + .filter(|token| !token.is_empty()) + { + headers.insert( + "x-amz-security-token", + HeaderValue::from_str(token).change_context_invalid_header("x-amz-security-token")?, + ); + } + + let (canonical_headers, signed_headers) = canonical_headers(headers)?; + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + method.as_str(), + canonical_uri(url), + canonical_query(url), + canonical_headers, + signed_headers, + UNSIGNED_PAYLOAD, + ); + let canonical_request_hash = hex_sha256(canonical_request.as_bytes()); + let credential_scope = format!("{date_stamp}/{region}/{SERVICE}/{TERMINATOR}"); + let string_to_sign = + format!("{ALGORITHM}\n{amz_date}\n{credential_scope}\n{canonical_request_hash}"); + let signing_key = signing_key(&credentials.secret_access_key, &date_stamp, region); + let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())); + let authorization = format!( + "{ALGORITHM} Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", + credentials.access_key_id, + ); + + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&authorization).change_context_invalid_header("authorization")?, + ); + + Ok(()) +} + +fn canonical_headers(headers: &HeaderMap) -> Result<(String, String), Report> { + let mut names = vec!["host", "x-amz-content-sha256", "x-amz-date"]; + if headers.contains_key("x-amz-security-token") { + names.push("x-amz-security-token"); + } + names.sort_unstable(); + + let mut canonical = String::new(); + for name in &names { + let value = headers.get(*name).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: format!("missing required S3 signing header `{name}`"), + }) + })?; + canonical.push_str(name); + canonical.push(':'); + canonical.push_str(&normalize_header_value(value)?); + canonical.push('\n'); + } + + Ok((canonical, names.join(";"))) +} + +fn normalize_header_value(value: &HeaderValue) -> Result> { + let value = value.to_str().map_err(|err| { + Report::new(TrustedServerError::InvalidHeaderValue { + message: format!("S3 signing header value is not valid text: {err}"), + }) + })?; + Ok(value.split_whitespace().collect::>().join(" ")) +} + +fn canonical_uri(url: &Url) -> String { + let path = url.path(); + if path.is_empty() { + return "/".to_string(); + } + + aws_percent_encode_preserving_escapes(path, false) +} + +fn canonical_query(url: &Url) -> String { + let Some(query) = url.query().filter(|query| !query.is_empty()) else { + return String::new(); + }; + + let mut pairs: Vec<(String, String)> = query + .split('&') + .map(|part| { + let (key, value) = part.split_once('=').unwrap_or((part, "")); + ( + aws_percent_encode_preserving_escapes(key, true), + aws_percent_encode_preserving_escapes(value, true), + ) + }) + .collect(); + pairs.sort_unstable(); + pairs + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&") +} + +fn aws_percent_encode_preserving_escapes(value: &str, encode_slash: bool) -> String { + let bytes = value.as_bytes(); + let mut out = String::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && index + 2 < bytes.len() + && bytes[index + 1].is_ascii_hexdigit() + && bytes[index + 2].is_ascii_hexdigit() + { + out.push('%'); + out.push(char::from(bytes[index + 1].to_ascii_uppercase())); + out.push(char::from(bytes[index + 2].to_ascii_uppercase())); + index += 3; + continue; + } + push_aws_percent_encoded_byte(&mut out, bytes[index], encode_slash); + index += 1; + } + out +} + +fn push_aws_percent_encoded_byte(out: &mut String, byte: u8, encode_slash: bool) { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(char::from(byte)); + } + b'/' if !encode_slash => out.push('/'), + other => out.push_str(&format!("%{other:02X}")), + } +} + +fn hex_sha256(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn hmac_sha256(key: &[u8], message: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("should create HMAC from arbitrary key"); + mac.update(message); + mac.finalize().into_bytes().to_vec() +} + +fn signing_key(secret_access_key: &str, date_stamp: &str, region: &str) -> Vec { + let date_key = hmac_sha256( + format!("AWS4{secret_access_key}").as_bytes(), + date_stamp.as_bytes(), + ); + let region_key = hmac_sha256(&date_key, region.as_bytes()); + let service_key = hmac_sha256(®ion_key, SERVICE.as_bytes()); + hmac_sha256(&service_key, TERMINATOR.as_bytes()) +} + +trait HeaderValueResultExt { + fn change_context_invalid_header(self, name: &str) -> Result>; +} + +impl HeaderValueResultExt for Result { + fn change_context_invalid_header(self, name: &str) -> Result> { + self.map_err(|err| { + Report::new(TrustedServerError::InvalidHeaderValue { + message: format!("invalid S3 signing header `{name}`: {err}"), + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signs_stable_s3_get_fixture() { + let url = Url::parse("https://examplebucket.s3.amazonaws.com/test.txt") + .expect("should parse URL"); + let mut headers = HeaderMap::new(); + headers.insert( + header::HOST, + HeaderValue::from_static("examplebucket.s3.amazonaws.com"), + ); + let credentials = S3Credentials { + access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), + secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), + session_token: None, + }; + let now = DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z") + .expect("should parse fixture date") + .with_timezone(&Utc) + .into(); + + sign_headers( + &Method::GET, + &url, + &mut headers, + "us-east-1", + &credentials, + now, + ) + .expect("should sign request"); + + assert_eq!( + headers.get("x-amz-content-sha256"), + Some(&HeaderValue::from_static(UNSIGNED_PAYLOAD)), + "should use unsigned payload for read-only asset requests" + ); + let authorization = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .expect("should set authorization header"); + assert_eq!( + authorization, + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=17ee2dc4ebe24953b3ebb4aad72c73aada1b27aa77109a55301af128fdcf571f", + "should include expected credential scope, signed headers, and signature" + ); + } + + #[test] + fn canonical_uri_preserves_existing_percent_encoded_path() { + let url = Url::parse("https://bucket.s3.us-east-1.amazonaws.com/foo%20bar/%e2%9c%93.jpg") + .expect("should parse URL"); + + assert_eq!(canonical_uri(&url), "/foo%20bar/%E2%9C%93.jpg"); + } + + #[test] + fn canonical_uri_encodes_raw_path_bytes_without_double_encoding() { + let url = Url::parse("https://bucket.s3.us-east-1.amazonaws.com/image*name%20raw.jpg") + .expect("should parse URL"); + + assert_eq!(canonical_uri(&url), "/image%2Aname%20raw.jpg"); + } + + #[test] + fn canonical_query_sorts_and_encodes() { + let url = Url::parse("https://bucket.s3.us-east-1.amazonaws.com/object?z=two&a=sp ace") + .expect("should parse URL"); + assert_eq!(canonical_query(&url), "a=sp%20ace&z=two"); + } + + #[test] + fn canonical_query_empty_query_is_empty() { + let url = Url::parse("https://bucket.s3.us-east-1.amazonaws.com/object?") + .expect("should parse URL"); + + assert_eq!(canonical_query(&url), ""); + } + + #[test] + fn canonical_query_preserves_plus_and_existing_escapes() { + let url = Url::parse( + "https://bucket.s3.us-east-1.amazonaws.com/object?v=a+b&space=a%20b&slash=a/b&encoded=a%2bb&empty", + ) + .expect("should parse URL"); + + assert_eq!( + canonical_query(&url), + "empty=&encoded=a%2Bb&slash=a%2Fb&space=a%20b&v=a%2Bb" + ); + } +} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0c1aa38a..1e4d1bcf 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -333,6 +333,647 @@ fn default_request_signing_enabled() -> bool { false } +fn default_s3_secret_store() -> String { + "s3-auth".to_string() +} + +fn default_s3_access_key_id() -> String { + "access_key_id".to_string() +} + +fn default_s3_secret_access_key() -> String { + "secret_access_key".to_string() +} + +fn default_asset_image_optimizer_enabled() -> bool { + true +} + +fn default_profile_param() -> String { + "profile".to_string() +} + +fn default_aspect_ratio_param() -> String { + "ar".to_string() +} + +fn default_debug_param() -> String { + "_io_debug".to_string() +} + +fn default_default_profile() -> String { + "default".to_string() +} + +fn default_crop_offset_x_param() -> String { + "x".to_string() +} + +fn default_crop_offset_y_param() -> String { + "y".to_string() +} + +fn default_crop_offset_buckets() -> Vec { + vec![10, 30, 50, 70, 90] +} + +fn default_crop_offset_value() -> u32 { + 50 +} + +/// Query-string handling policy for upstream origin requests. +/// +/// Plain asset routes default to [`Self::Preserve`]. Image-optimized asset +/// routes default to [`Self::Strip`] because transformation query parameters are +/// not usually part of the origin object identity. +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OriginQueryPolicy { + /// Preserve the incoming query string on the origin request. + Preserve, + /// Strip the incoming query string before sending to origin. + Strip, +} + +/// Authentication configuration for an asset origin. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AssetOriginAuth { + /// Sign asset origin requests with AWS Signature Version 4 for `S3`. + #[serde(rename = "s3_sigv4", alias = "s3_sig_v4")] + S3SigV4(S3SigV4AuthConfig), +} + +impl AssetOriginAuth { + fn normalize(&mut self) { + match self { + Self::S3SigV4(config) => config.normalize(), + } + } + + fn prepare_runtime(&self) -> Result<(), Report> { + match self { + Self::S3SigV4(config) => config.prepare_runtime(), + } + } + + /// Return the configured origin query policy, if any. + #[must_use] + pub fn origin_query_policy(&self) -> Option { + match self { + Self::S3SigV4(config) => config.origin_query, + } + } +} + +/// AWS Signature Version 4 configuration for `S3` asset origins. +/// +/// The route `origin_url` must use the same `S3` host that `AWS` validates in +/// the `SigV4` canonical request. Credentials are read from the named runtime +/// secret store for each proxied asset request. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct S3SigV4AuthConfig { + /// `AWS` region used in the credential scope. + pub region: String, + /// Runtime secret store containing `S3` credentials. + #[serde(default = "default_s3_secret_store")] + pub secret_store: String, + /// Secret name containing the `AWS` access key ID. + #[serde(default = "default_s3_access_key_id")] + pub access_key_id: String, + /// Secret name containing the `AWS` secret access key. + #[serde(default = "default_s3_secret_access_key")] + pub secret_access_key: String, + /// Optional secret name containing an `AWS` session token. + #[serde(default)] + pub session_token: Option, + /// Query-string handling policy for the signed `S3` origin request. + /// + /// Set this to `strip` when request query parameters are transformation + /// inputs rather than `S3` object identity. If omitted, image-optimized routes + /// strip queries and plain routes preserve them. + #[serde(default)] + pub origin_query: Option, +} + +impl S3SigV4AuthConfig { + fn normalize(&mut self) { + self.region = self.region.trim().to_string(); + self.secret_store = self.secret_store.trim().to_string(); + self.access_key_id = self.access_key_id.trim().to_string(); + self.secret_access_key = self.secret_access_key.trim().to_string(); + self.session_token = self + .session_token + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + + fn prepare_runtime(&self) -> Result<(), Report> { + if self.region.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "proxy.asset_routes auth s3_sigv4 region must not be empty".to_string(), + })); + } + if self.secret_store.is_empty() + || self.access_key_id.is_empty() + || self.secret_access_key.is_empty() + { + return Err(Report::new(TrustedServerError::Configuration { + message: "proxy.asset_routes auth s3_sigv4 secret names must not be empty" + .to_string(), + })); + } + Ok(()) + } +} + +/// Route-level Image Optimizer configuration for asset proxying. +/// +/// This block only selects the processing region and profile set. The actual +/// transformation table lives under top-level [`ImageOptimizerSettings`] so +/// multiple routes can share one closed set of profiles. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AssetImageOptimizerConfig { + /// Enables Image Optimizer for this route when the table is present. + #[serde(default = "default_asset_image_optimizer_enabled")] + pub enabled: bool, + /// Image Optimizer processing region. + pub region: String, + /// Name of the top-level profile set used to convert request query params. + pub profile_set: String, + /// Query-string handling policy for the origin request. + /// + /// `preserve` is rejected while Image Optimizer is enabled because Fastly `IO` + /// can interpret arbitrary request query parameters as transformation + /// inputs outside the configured profile table. + #[serde(default)] + pub origin_query: Option, +} + +impl AssetImageOptimizerConfig { + fn normalize(&mut self) { + self.region = self.region.trim().to_string(); + self.profile_set = self.profile_set.trim().to_string(); + } + + fn prepare_runtime(&self) -> Result<(), Report> { + if !self.enabled { + return Ok(()); + } + if self.region.is_empty() || self.profile_set.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: + "proxy.asset_routes image_optimizer region and profile_set must not be empty" + .to_string(), + })); + } + Ok(()) + } +} + +/// Behavior when a requested image profile is missing or unknown. +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnknownProfilePolicy { + /// Use the configured default profile. + #[default] + UseDefault, + /// Reject the request. + Reject, +} + +/// Top-level reusable Image Optimizer configuration. +/// +/// Profile sets are keyed by arbitrary deployment-local names. Keep customer or +/// site-specific profile tables in private configuration overlays when those +/// values should not be committed to the public repository. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct ImageOptimizerSettings { + /// Named profile sets referenced by asset routes. + #[serde(default)] + pub profile_sets: HashMap, +} + +impl ImageOptimizerSettings { + fn normalize(&mut self) { + self.profile_sets = self + .profile_sets + .drain() + .map(|(key, mut profile_set)| { + profile_set.normalize(); + (key.trim().to_string(), profile_set) + }) + .filter(|(key, _)| !key.is_empty()) + .collect(); + } + + /// Eagerly validate configured image profile sets. + pub(crate) fn prepare_runtime(&self) -> Result<(), Report> { + for (name, profile_set) in &self.profile_sets { + profile_set.prepare_runtime(name)?; + } + Ok(()) + } +} + +/// Named set of profile-table Image Optimizer mappings. +/// +/// Each profile value is a URL-encoded parameter string using the strict +/// supported subset: `quality`, `resize-filter`, `format`, `width`, `height`, +/// and `crop`. Profile-specific parameters override [`Self::base_params`]. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImageOptimizerProfileSet { + /// Params applied to every profile before profile-specific params. + #[serde(default)] + pub base_params: String, + /// Profile used when the query omits or does not recognize a profile. + #[serde(default = "default_default_profile")] + pub default_profile: String, + /// Unknown profile handling policy. + #[serde(default)] + pub unknown_profile: UnknownProfilePolicy, + /// Query parameter that carries the profile name. + #[serde(default = "default_profile_param")] + pub profile_param: String, + /// Query parameter that carries an aspect ratio override. + #[serde(default = "default_aspect_ratio_param")] + pub aspect_ratio_param: String, + /// Query parameter that disables `IO` for a request when set to `1`. + #[serde(default = "default_debug_param")] + pub debug_param: String, + /// Profile name to IO param string mapping. + /// + /// Values use query-string syntax, for example `format=auto&width=828`. + #[serde(default)] + pub profiles: HashMap, + /// Optional aspect-ratio override rules. + #[serde(default)] + pub aspect_ratios: Option, + /// Optional crop offset bucketing rules. + #[serde(default)] + pub crop_offsets: Option, +} + +impl ImageOptimizerProfileSet { + fn normalize(&mut self) { + self.base_params = self.base_params.trim().to_string(); + self.default_profile = self.default_profile.trim().to_string(); + self.profile_param = self.profile_param.trim().to_string(); + self.aspect_ratio_param = self.aspect_ratio_param.trim().to_string(); + self.debug_param = self.debug_param.trim().to_string(); + self.profiles = self + .profiles + .drain() + .map(|(key, value)| (key.trim().to_string(), value.trim().to_string())) + .filter(|(key, _)| !key.is_empty()) + .collect(); + if let Some(config) = &mut self.aspect_ratios { + config.normalize(); + } + if let Some(config) = &mut self.crop_offsets { + config.normalize(); + } + } + + fn prepare_runtime(&self, name: &str) -> Result<(), Report> { + if self.default_profile.is_empty() + || self.profile_param.is_empty() + || self.aspect_ratio_param.is_empty() + || self.debug_param.is_empty() + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{name}` parameter names and default_profile must not be empty" + ), + })); + } + if !self.profiles.contains_key(&self.default_profile) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{name}` default_profile `{}` is not defined", + self.default_profile + ), + })); + } + validate_image_optimizer_profile_set(name, self)?; + if let Some(config) = &self.aspect_ratios { + config.prepare_runtime(name)?; + } + if let Some(config) = &self.crop_offsets { + config.prepare_runtime(name)?; + } + Ok(()) + } +} + +/// Aspect-ratio override configuration for an Image Optimizer profile set. +/// +/// When a request uses an allowed profile and an allowed ratio value, the +/// profile crop is replaced with an aspect-ratio crop derived from the request +/// query value. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImageOptimizerAspectRatioConfig { + /// Allowed aspect ratio query values such as `1-1` or `16-9`. + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub allowed: Vec, + /// Profiles that accept aspect-ratio overrides. + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub profiles: Vec, +} + +impl ImageOptimizerAspectRatioConfig { + fn normalize(&mut self) { + self.allowed = self + .allowed + .iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + self.profiles = self + .profiles + .iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + } + + fn prepare_runtime(&self, name: &str) -> Result<(), Report> { + for ratio in &self.allowed { + if parse_aspect_ratio_value(ratio).is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{name}` aspect ratio `{ratio}` must look like `width-height`" + ), + })); + } + } + Ok(()) + } +} + +/// Behavior when a bare crop has no explicit x/y offsets. +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MissingCropOffsetMode { + /// Append Fastly `IO` `smart` crop mode. + #[default] + Smart, + /// Leave the crop as-is. + None, +} + +/// Crop offset normalization configuration. +/// +/// Offset bucketing caps output variant cardinality. Request values outside +/// `0..=100` or values that fail to parse fall back to [`Self::default`]. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImageOptimizerCropOffsetsConfig { + /// Enable crop offset normalization. + #[serde(default = "default_asset_image_optimizer_enabled")] + pub enabled: bool, + /// Query parameter containing the x-axis offset. + #[serde(default = "default_crop_offset_x_param")] + pub x_param: String, + /// Query parameter containing the y-axis offset. + #[serde(default = "default_crop_offset_y_param")] + pub y_param: String, + /// Sorted offset buckets used to cap variant cardinality. + #[serde( + default = "default_crop_offset_buckets", + deserialize_with = "vec_from_seq_or_map" + )] + pub buckets: Vec, + /// Default offset used when input is missing or invalid. + #[serde(default = "default_crop_offset_value")] + pub default: u32, + /// Behavior when neither x nor y is present. + #[serde(default)] + pub when_missing: MissingCropOffsetMode, +} + +impl ImageOptimizerCropOffsetsConfig { + fn normalize(&mut self) { + self.x_param = self.x_param.trim().to_string(); + self.y_param = self.y_param.trim().to_string(); + self.buckets.sort_unstable(); + self.buckets.dedup(); + } + + fn prepare_runtime(&self, name: &str) -> Result<(), Report> { + if self.x_param.is_empty() || self.y_param.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{name}` crop offset param names must not be empty" + ), + })); + } + if self.buckets.is_empty() + || self.buckets.iter().any(|bucket| *bucket > 100) + || self.default > 100 + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{name}` crop offset buckets/default must be in 0..=100" + ), + })); + } + Ok(()) + } +} + +fn parse_aspect_ratio_value(value: &str) -> Option<(u32, u32)> { + let (width, height) = value.split_once('-')?; + let width = width.parse::().ok()?; + let height = height.parse::().ok()?; + if width == 0 || height == 0 { + return None; + } + Some((width, height)) +} + +fn validate_image_optimizer_profile_set( + name: &str, + profile_set: &ImageOptimizerProfileSet, +) -> Result<(), Report> { + validate_image_optimizer_param_string(name, "base_params", &profile_set.base_params)?; + for (profile_name, params) in &profile_set.profiles { + validate_image_optimizer_param_string(name, profile_name, params)?; + } + Ok(()) +} + +fn validate_image_optimizer_param_string( + set_name: &str, + profile_name: &str, + params: &str, +) -> Result<(), Report> { + for (key, value) in url::form_urlencoded::parse(params.as_bytes()) { + match key.as_ref() { + "format" => validate_image_optimizer_format(set_name, profile_name, value.as_ref())?, + "quality" => { + validate_bounded_u32_param( + set_name, + profile_name, + "quality", + value.as_ref(), + 0, + 100, + )?; + } + "resize-filter" => { + validate_resize_filter(set_name, profile_name, value.as_ref())?; + } + "width" | "height" => { + validate_positive_u32_param(set_name, profile_name, key.as_ref(), value.as_ref())?; + } + "crop" => validate_crop_param(set_name, profile_name, value.as_ref())?, + unsupported => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` uses unsupported parameter `{unsupported}`" + ), + })); + } + } + } + Ok(()) +} + +fn validate_image_optimizer_format( + set_name: &str, + profile_name: &str, + value: &str, +) -> Result<(), Report> { + match value.trim().to_ascii_lowercase().as_str() { + "auto" | "avif" | "gif" | "jpeg" | "jpg" | "jxl" | "jpegxl" | "mp4" | "png" + | "webp" => Ok(()), + _ => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` has unsupported format `{value}`" + ), + })), + } +} + +fn validate_resize_filter( + set_name: &str, + profile_name: &str, + value: &str, +) -> Result<(), Report> { + match value.trim().to_ascii_lowercase().as_str() { + "nearest" | "bilinear" | "bicubic" | "lanczos2" | "lanczos3" => Ok(()), + _ => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` has unsupported resize-filter `{value}`" + ), + })), + } +} + +fn validate_positive_u32_param( + set_name: &str, + profile_name: &str, + param_name: &str, + value: &str, +) -> Result<(), Report> { + let parsed = value.parse::().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` parameter `{param_name}` must be an integer: {err}" + ), + }) + })?; + if parsed == 0 { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` parameter `{param_name}` must be greater than zero" + ), + })); + } + Ok(()) +} + +fn validate_bounded_u32_param( + set_name: &str, + profile_name: &str, + param_name: &str, + value: &str, + min: u32, + max: u32, +) -> Result<(), Report> { + let parsed = value.parse::().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` parameter `{param_name}` must be an integer: {err}" + ), + }) + })?; + if parsed < min || parsed > max { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` parameter `{param_name}` must be in {min}..={max}" + ), + })); + } + Ok(()) +} + +fn validate_crop_param( + set_name: &str, + profile_name: &str, + value: &str, +) -> Result<(), Report> { + let mut parts = value.split(','); + let ratio = parts.next().unwrap_or_default(); + let Some((width, height)) = ratio.split_once(':') else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` crop `{value}` must look like `width:height`" + ), + })); + }; + validate_positive_u32_param(set_name, profile_name, "crop width", width)?; + validate_positive_u32_param(set_name, profile_name, "crop height", height)?; + + let mut has_smart = false; + let mut has_offset_x = false; + let mut has_offset_y = false; + for suffix in parts { + if suffix == "smart" { + has_smart = true; + } else if let Some(offset) = suffix.strip_prefix("offset-x") { + validate_bounded_u32_param(set_name, profile_name, "crop offset-x", offset, 0, 100)?; + has_offset_x = true; + } else if let Some(offset) = suffix.strip_prefix("offset-y") { + validate_bounded_u32_param(set_name, profile_name, "crop offset-y", offset, 0, 100)?; + has_offset_y = true; + } else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` crop has unsupported suffix `{suffix}`" + ), + })); + } + } + + if has_smart && (has_offset_x || has_offset_y) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` crop cannot combine smart with offsets" + ), + })); + } + if has_offset_x != has_offset_y { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "image_optimizer.profile_sets `{set_name}` profile `{profile_name}` crop offsets must include both offset-x and offset-y" + ), + })); + } + Ok(()) +} + /// A path-prefix asset route that proxies matched first-party requests to an alternate origin. #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct ProxyAssetRoute { @@ -355,6 +996,12 @@ pub struct ProxyAssetRoute { /// Must be configured together with [`Self::path_pattern`] and must produce a /// path that starts with `/`. pub target_path: Option, + /// Optional origin authentication configuration. + #[serde(default)] + pub auth: Option, + /// Optional Image Optimizer configuration. + #[serde(default)] + pub image_optimizer: Option, #[serde(skip, default)] compiled_pattern: OnceLock>, } @@ -383,6 +1030,12 @@ impl ProxyAssetRoute { .take() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); + if let Some(auth) = &mut self.auth { + auth.normalize(); + } + if let Some(image_optimizer) = &mut self.image_optimizer { + image_optimizer.normalize(); + } } fn compiled_path_pattern(&self) -> Result, Report> { @@ -489,8 +1142,58 @@ impl ProxyAssetRoute { } } + if let Some(auth) = &self.auth { + auth.prepare_runtime()?; + } + if let Some(image_optimizer) = &self.image_optimizer { + image_optimizer.prepare_runtime()?; + } + if self.image_optimizer_enabled() + && self.origin_query_policy() == OriginQueryPolicy::Preserve + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes prefix `{}` cannot preserve origin query while image_optimizer is enabled; profile-table IO requires origin_query = \"strip\"", + self.prefix + ), + })); + } + self.compiled_path_pattern().map(|_| ()) } + + /// Return true when this route has enabled Image Optimizer configuration. + #[must_use] + pub fn image_optimizer_enabled(&self) -> bool { + self.image_optimizer + .as_ref() + .is_some_and(|config| config.enabled) + } + + /// Return the effective origin query policy for this asset route. + #[must_use] + pub fn origin_query_policy(&self) -> OriginQueryPolicy { + if let Some(policy) = self + .auth + .as_ref() + .and_then(AssetOriginAuth::origin_query_policy) + { + return policy; + } + if let Some(policy) = self + .image_optimizer + .as_ref() + .filter(|config| config.enabled) + .and_then(|config| config.origin_query) + { + return policy; + } + if self.image_optimizer_enabled() { + OriginQueryPolicy::Strip + } else { + OriginQueryPolicy::Preserve + } + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -649,6 +1352,8 @@ pub struct Settings { #[serde(default)] pub proxy: Proxy, #[serde(default)] + pub image_optimizer: ImageOptimizerSettings, + #[serde(default)] pub debug: DebugConfig, } @@ -669,6 +1374,7 @@ impl Settings { })?; settings.proxy.normalize(); + settings.image_optimizer.normalize(); settings.consent.validate(); settings.prepare_runtime()?; settings.validate_admin_coverage()?; @@ -707,6 +1413,7 @@ impl Settings { settings.integrations.normalize(); settings.proxy.normalize(); + settings.image_optimizer.normalize(); settings.consent.validate(); settings.validate().map_err(|err| { @@ -727,7 +1434,9 @@ impl Settings { /// /// Returns a configuration error if any cached runtime artifact cannot be prepared. pub fn prepare_runtime(&self) -> Result<(), Report> { + self.image_optimizer.prepare_runtime()?; self.proxy.prepare_runtime()?; + self.validate_asset_image_optimizer_profile_sets()?; for handler in &self.handlers { handler.prepare_runtime()?; @@ -736,6 +1445,32 @@ impl Settings { Ok(()) } + fn validate_asset_image_optimizer_profile_sets( + &self, + ) -> Result<(), Report> { + for route in &self.proxy.asset_routes { + let Some(config) = &route.image_optimizer else { + continue; + }; + if !config.enabled { + continue; + } + if !self + .image_optimizer + .profile_sets + .contains_key(&config.profile_set) + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "proxy.asset_routes prefix `{}` references unknown image_optimizer profile_set `{}`", + route.prefix, config.profile_set + ), + })); + } + } + Ok(()) + } + /// Resolve the longest matching asset route for the request path. #[must_use] pub fn asset_route_for_path(&self, path: &str) -> Option<&ProxyAssetRoute> { @@ -2202,6 +2937,111 @@ mod tests { ); } + #[test] + fn proxy_asset_route_auth_and_image_optimizer_parse_from_toml() { + let toml_str = crate_test_settings_str() + + r#" + [image_optimizer.profile_sets.default_images] + base_params = "quality=70&resize-filter=bicubic" + default_profile = "default" + unknown_profile = "use_default" + + [image_optimizer.profile_sets.default_images.profiles] + default = "width=1920" + medium = "format=auto&width=828" + + [image_optimizer.profile_sets.default_images.aspect_ratios] + allowed = ["1-1", "16-9"] + profiles = ["medium"] + + [image_optimizer.profile_sets.default_images.crop_offsets] + enabled = true + buckets = [10, 30, 50, 70, 90] + default = 50 + + [proxy] + + [[proxy.asset_routes]] + prefix = "/.image/" + origin_url = "https://bucket.s3.us-east-1.amazonaws.com" + + [proxy.asset_routes.auth] + type = "s3_sigv4" + region = "us-east-1" + origin_query = "strip" + + [proxy.asset_routes.image_optimizer] + enabled = true + region = "us_east" + profile_set = "default_images" + "#; + + let settings = Settings::from_toml(&toml_str) + .expect("should parse S3 auth and image optimizer asset route"); + let route = settings + .asset_route_for_path("/.image/id/example.jpg") + .expect("should match configured route"); + assert!(route.image_optimizer_enabled()); + assert_eq!(route.origin_query_policy(), OriginQueryPolicy::Strip); + match route.auth.as_ref().expect("should configure route auth") { + AssetOriginAuth::S3SigV4(config) => { + assert_eq!(config.region, "us-east-1"); + assert_eq!(config.secret_store, "s3-auth"); + assert_eq!(config.access_key_id, "access_key_id"); + assert_eq!(config.secret_access_key, "secret_access_key"); + } + } + } + + #[test] + fn proxy_asset_route_validation_rejects_image_optimizer_preserve_query() { + let toml_str = crate_test_settings_str() + + r#" + [image_optimizer.profile_sets.default_images] + base_params = "quality=70" + + [image_optimizer.profile_sets.default_images.profiles] + default = "width=1920" + + [proxy] + + [[proxy.asset_routes]] + prefix = "/.image/" + origin_url = "https://bucket.s3.us-east-1.amazonaws.com" + + [proxy.asset_routes.image_optimizer] + enabled = true + region = "us_east" + profile_set = "default_images" + origin_query = "preserve" + "#; + let err = Settings::from_toml(&toml_str) + .expect_err("should reject preserving arbitrary client query with IO enabled"); + + assert!( + format!("{err:?}") + .contains("cannot preserve origin query while image_optimizer is enabled"), + "should mention the rejected IO origin query policy: {err:?}" + ); + } + + #[test] + fn proxy_asset_route_disabled_image_optimizer_does_not_override_origin_query_policy() { + let route = ProxyAssetRoute { + prefix: "/.image/".to_string(), + origin_url: "https://assets.example.com".to_string(), + image_optimizer: Some(AssetImageOptimizerConfig { + enabled: false, + region: "us_east".to_string(), + profile_set: "default_images".to_string(), + origin_query: Some(OriginQueryPolicy::Strip), + }), + ..Default::default() + }; + + assert_eq!(route.origin_query_policy(), OriginQueryPolicy::Preserve); + } + #[test] fn proxy_asset_route_validation_rejects_incomplete_rewrite() { let toml_str = crate_test_settings_str() diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index ec5f8ddf..ccfddceb 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -128,7 +128,7 @@ impl StreamingPipeline

{ /// /// Handles all supported compression transformations by wrapping the raw /// reader/writer in the appropriate decoder/encoder, then delegating to - /// [`Self::process_chunks`]. + /// `Self::process_chunks`. /// /// # Errors /// diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 4921a9bb..9c90187c 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -22,7 +22,7 @@ pub fn tsjs_script_tag(module_ids: &[&str]) -> String { /// Hashes all compiled module IDs so the cache invalidates whenever any module /// changes. Over-invalidates slightly (includes deferred modules in the hash) /// but never serves stale content. Use [`tsjs_script_src`] with exact module -/// IDs when the [`IntegrationRegistry`] is available. +/// IDs when `IntegrationRegistry` is available. #[must_use] pub fn tsjs_unified_script_src() -> String { let ids = all_module_ids(); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d66a820c..41e46ed0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -85,6 +85,7 @@ export default withMermaid( link: '/guide/auction-orchestration', }, { text: 'First-Party Proxy', link: '/guide/first-party-proxy' }, + { text: 'Asset Routes', link: '/guide/asset-routes' }, { text: 'Creative Processing', link: '/guide/creative-processing' }, { text: 'Integrations Overview', diff --git a/docs/guide/asset-routes.md b/docs/guide/asset-routes.md new file mode 100644 index 00000000..7ca00743 --- /dev/null +++ b/docs/guide/asset-routes.md @@ -0,0 +1,201 @@ +# Asset Routes + +Asset routes proxy selected first-party paths to an alternate asset origin. They are useful when a publisher-facing URL should stay stable while the bytes come from a CDN, a static origin, or a private S3 bucket. + +Asset routes are separate from signed `/first-party/proxy` URLs. They match configured path prefixes directly, do not require `tstoken`, and are intended for publisher-owned asset paths such as images and static files. + +## Request flow + +For a matching `GET` or `HEAD` request, Trusted Server runs this sequence: + +1. Match the longest configured `proxy.asset_routes` prefix. +2. Optionally rewrite the request path with `path_pattern` and `target_path`. +3. Build Image Optimizer metadata from the request query when the route enables it. +4. Apply the origin query policy. +5. Add origin authentication headers when the route configures auth. +6. Send the request to the resolved backend origin. +7. Return the origin status, body, and safe headers. + +The origin query policy runs before S3 signing, so the signature covers the exact URL sent to S3. Image Optimizer metadata stays separate from the origin URL and is translated by the Fastly adapter. + +## Basic route + +```toml +[proxy] + +[[proxy.asset_routes]] +prefix = "/assets/" +origin_url = "https://assets.example.com" +``` + +A request for `/assets/logo.png?v=1` is sent to: + +```text +https://assets.example.com/assets/logo.png?v=1 +``` + +Plain asset routes preserve the incoming query string by default. + +## Path rewrite + +Use `path_pattern` and `target_path` when the public path shape differs from the origin object path. + +```toml +[[proxy.asset_routes]] +prefix = "/.image/" +origin_url = "https://assets-cdn.example.com" +path_pattern = "^/\\.image/(.*)/[^/]+\\.([^/.]+)$" +target_path = "/image/upload/$1.$2" +``` + +Both fields must be configured together. The rewritten path must start with `/`. + +## Private S3 origins + +Add an auth block when the asset origin is a private S3 bucket. + +```toml +[[proxy.asset_routes]] +prefix = "/.image/" +origin_url = "https://bucket.s3.us-east-1.amazonaws.com" + +[proxy.asset_routes.auth] +type = "s3_sigv4" +region = "us-east-1" +origin_query = "strip" +secret_store = "s3-auth" +access_key_id = "access_key_id" +secret_access_key = "secret_access_key" +# session_token = "session_token" +``` + +### S3 requirements + +- `origin_url` must be the real S3 host that AWS validates in the SigV4 canonical request. +- S3 support is for `GET` and `HEAD` asset reads. +- Signing uses header-based AWS SigV4, not presigned URLs. +- The signer uses `x-amz-content-sha256: UNSIGNED-PAYLOAD`. +- Credentials are loaded from the configured runtime secret store. +- Existing client `Authorization` and `x-amz-*` signing headers are replaced before signing. + +### Secret store values + +The default secret store and key names are: + +| Config field | Default value | Secret value | +| ------------------- | ------------------- | ------------------------------------ | +| `secret_store` | `s3-auth` | Secret store name | +| `access_key_id` | `access_key_id` | AWS access key ID | +| `secret_access_key` | `secret_access_key` | AWS secret access key | +| `session_token` | unset | Optional AWS temporary session token | + +Use private deployment configuration for environment-specific store names or profile tables. + +## Origin query policy + +`origin_query` controls whether the upstream origin receives the browser query string. + +| Value | Behavior | +| ---------- | --------------------------------------------------- | +| `preserve` | Keep the incoming query string on the origin URL | +| `strip` | Remove the incoming query string before origin send | + +Defaults: + +- Plain asset routes default to `preserve`. +- Image-optimized asset routes default to `strip`. +- `proxy.asset_routes.auth.origin_query` overrides the route default. +- Enabled Image Optimizer routes reject `preserve` to avoid arbitrary client query parameters becoming transformation inputs. + +## Fastly Image Optimizer profiles + +Image Optimizer support is configured in two places: + +1. A top-level reusable profile set under `[image_optimizer.profile_sets.]`. +2. A route-level `[proxy.asset_routes.image_optimizer]` block that selects the profile set and processing region. + +```toml +[image_optimizer.profile_sets.default_images] +base_params = "quality=70&resize-filter=bicubic" +default_profile = "default" +unknown_profile = "use_default" +profile_param = "profile" +aspect_ratio_param = "ar" +debug_param = "_io_debug" + +[image_optimizer.profile_sets.default_images.profiles] +default = "width=1920" +medium = "format=auto&width=828" +thumbnail = "width=150&crop=1:1,smart" + +[image_optimizer.profile_sets.default_images.aspect_ratios] +allowed = ["1-1", "16-9", "4-3"] +profiles = ["medium"] + +[image_optimizer.profile_sets.default_images.crop_offsets] +enabled = true +x_param = "x" +y_param = "y" +buckets = [10, 30, 50, 70, 90] +default = 50 +when_missing = "smart" + +[[proxy.asset_routes]] +prefix = "/.image/" +origin_url = "https://bucket.s3.us-east-1.amazonaws.com" + +[proxy.asset_routes.auth] +type = "s3_sigv4" +region = "us-east-1" +origin_query = "strip" + +[proxy.asset_routes.image_optimizer] +enabled = true +region = "us_east" +profile_set = "default_images" +``` + +A request such as: + +```text +/.image/id/example.jpg?profile=medium&ar=1-1&x=44&y=63 +``` + +uses the `medium` profile, applies the allowed `1-1` crop override, buckets offsets to the configured values, strips the origin query, signs the S3 request if auth is enabled, and sends Fastly IO metadata through the Fastly adapter. + +## Supported profile parameters + +Profile strings intentionally accept a strict subset of Image Optimizer parameters. + +| Parameter | Example value | Notes | +| --------------- | ------------- | -------------------------------------------------------------------------------- | +| `quality` | `70` | Integer in `0..=100` | +| `resize-filter` | `bicubic` | `nearest`, `bilinear`, `bicubic`, `lanczos2`, `lanczos3` | +| `format` | `auto` | Also accepts `avif`, `gif`, `jpeg`, `jpg`, `jxl`, `jpegxl`, `mp4`, `png`, `webp` | +| `width` | `828` | Positive integer pixels | +| `height` | `466` | Positive integer pixels | +| `crop` | `1:1,smart` | Aspect ratio, optional `smart`, or paired `offset-xN,offset-yN` suffixes | + +Unknown profile parameters are configuration errors. Arbitrary client query parameters are not forwarded as Image Optimizer options. + +## Debug bypass + +Set the configured debug parameter to `1` to disable Image Optimizer for one request: + +```text +/.image/id/example.jpg?profile=medium&_io_debug=1 +``` + +The asset route still matches. Path rewrite, origin query policy, and S3 signing still run. The response comes from the unoptimized origin object. + +## Local testing notes + +Viceroy does not perform real Fastly Image Optimizer transformations. Local tests can verify routing, query stripping, SigV4 headers, and metadata attachment. End-to-end image transformation verification requires a deployed Fastly Compute service with Image Optimizer enabled. + +## Security checklist + +- Use a private S3 bucket policy that permits only the configured AWS principal. +- Store AWS credentials in Fastly Secret Store or an equivalent runtime secret store. +- Keep customer-specific profile names and profile tables in private deployment config when needed. +- Use `origin_query = "strip"` for image transformation routes unless the query string is part of the S3 object identity. +- Configure narrow path prefixes so asset routes do not capture unrelated application paths. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index bfa655d9..62fc9889 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -61,7 +61,8 @@ openssl rand -base64 32 | ------------------- | -------------------------------------------- | | `[publisher]` | Domain, origin, proxy settings | | `[edge_cookie]` | Edge Cookie (EC) ID generation | -| `[proxy]` | Proxy SSRF allowlist | +| `[proxy]` | Proxy SSRF allowlist and asset routes | +| `[image_optimizer]` | Reusable Image Optimizer profile sets | | `[request_signing]` | Ed25519 request signing | | `[auction]` | Auction orchestration | | `[integrations.*]` | Partner integrations (Prebid, Next.js, etc.) | @@ -608,13 +609,15 @@ See [Creative Processing](/guide/creative-processing#exclude-domains) for detail ## Proxy Configuration -Controls first-party proxy security settings. +Controls first-party proxy security settings and path-based asset routes. ### `[proxy]` -| Field | Type | Required | Description | -| ----------------- | ------------- | ------------------ | ------------------------------------------------------ | -| `allowed_domains` | Array[String] | No (default: `[]`) | Redirect destinations the proxy is permitted to follow | +| Field | Type | Required | Description | +| ------------------- | ------------- | -------------------- | ------------------------------------------------------ | +| `allowed_domains` | Array[String] | No (default: `[]`) | Redirect destinations the proxy is permitted to follow | +| `certificate_check` | Boolean | No (default: `true`) | Verify TLS certificates when proxying HTTPS origins | +| `asset_routes` | Array[Table] | No (default: `[]`) | Path prefixes proxied directly to configured origins | **Example**: @@ -679,6 +682,175 @@ allowed_domains = [ See [First-Party Proxy](/guide/first-party-proxy#proxy-allowlist) for usage details. +#### `certificate_check` + +**Purpose**: Control TLS certificate verification for HTTPS proxy and asset-route origins. + +**Default**: `true` + +Set this to `false` only for local development with self-signed certificates. + +### `[[proxy.asset_routes]]` + +Asset routes proxy selected first-party paths to an alternate asset origin without requiring signed `/first-party/proxy` URLs. + +| Field | Type | Required | Description | +| ----------------- | ------ | -------- | ----------------------------------------------- | +| `prefix` | String | Yes | Request path prefix to match | +| `origin_url` | String | Yes | Absolute `http` or `https` origin URL | +| `path_pattern` | String | No | Regex matched against the incoming request path | +| `target_path` | String | No | Replacement path used with `path_pattern` | +| `auth` | Table | No | Optional origin authentication | +| `image_optimizer` | Table | No | Optional route-level Image Optimizer settings | + +**Example**: + +```toml +[[proxy.asset_routes]] +prefix = "/assets/" +origin_url = "https://assets.example.com" +``` + +**Path rewrite example**: + +```toml +[[proxy.asset_routes]] +prefix = "/.image/" +origin_url = "https://assets-cdn.example.com" +path_pattern = "^/\\.image/(.*)/[^/]+\\.([^/.]+)$" +target_path = "/image/upload/$1.$2" +``` + +**Behavior**: + +- Only `GET` and `HEAD` requests use asset routes. +- Built-in and integration routes take precedence. +- The longest matching asset-route prefix wins. +- `path_pattern` and `target_path` must be configured together. +- `origin_url` must not include a path or query string. +- Unsafe origin response headers such as `Set-Cookie` are stripped before the response reaches the browser. + +### `[proxy.asset_routes.auth]` + +The first supported origin auth type is `s3_sigv4`. + +| Field | Type | Required | Default | Description | +| ------------------- | ------ | -------- | ------------------- | ----------------------------------------------- | +| `type` | String | Yes | none | Must be `s3_sigv4` | +| `region` | String | Yes | none | AWS region used in the SigV4 credential scope | +| `secret_store` | String | No | `s3-auth` | Runtime secret store containing AWS credentials | +| `access_key_id` | String | No | `access_key_id` | Secret key containing the AWS access key ID | +| `secret_access_key` | String | No | `secret_access_key` | Secret key containing the AWS secret access key | +| `session_token` | String | No | unset | Optional secret key containing a session token | +| `origin_query` | String | No | route default | `preserve` or `strip` | + +**Example**: + +```toml +[[proxy.asset_routes]] +prefix = "/.image/" +origin_url = "https://bucket.s3.us-east-1.amazonaws.com" + +[proxy.asset_routes.auth] +type = "s3_sigv4" +region = "us-east-1" +origin_query = "strip" +secret_store = "s3-auth" +access_key_id = "access_key_id" +secret_access_key = "secret_access_key" +# session_token = "session_token" +``` + +S3 auth uses header-based AWS SigV4 with `UNSIGNED-PAYLOAD`. It is scoped to read-only asset requests and expects `origin_url` to use the S3 host that AWS validates. + +### `[proxy.asset_routes.image_optimizer]` + +Route-level Image Optimizer configuration selects a reusable profile set. + +| Field | Type | Required | Default | Description | +| -------------- | ------- | ---------------- | -------------------- | ----------------------------------------------------------------- | +| `enabled` | Boolean | No | `true` | Enable Image Optimizer for the route | +| `region` | String | Yes when enabled | none | Fastly IO processing region, such as `us_east` | +| `profile_set` | String | Yes when enabled | none | Name under `[image_optimizer.profile_sets.*]` | +| `origin_query` | String | No | `strip` when enabled | `preserve` or `strip`; `preserve` is rejected while IO is enabled | + +**Example**: + +```toml +[proxy.asset_routes.image_optimizer] +enabled = true +region = "us_east" +profile_set = "default_images" +``` + +### `[image_optimizer.profile_sets.]` + +Profile sets convert small request query controls into a closed set of Image Optimizer parameters. + +| Field | Type | Required | Default | Description | +| -------------------- | ------ | -------- | ------------- | ------------------------------------------------ | +| `base_params` | String | No | `""` | Params applied before profile-specific params | +| `default_profile` | String | No | `default` | Profile used when no profile is requested | +| `unknown_profile` | String | No | `use_default` | `use_default` or `reject` | +| `profile_param` | String | No | `profile` | Query parameter containing the profile name | +| `aspect_ratio_param` | String | No | `ar` | Query parameter containing aspect ratio | +| `debug_param` | String | No | `_io_debug` | Query parameter that disables IO when set to `1` | + +Profile values live under `[image_optimizer.profile_sets..profiles]` and use query-string syntax. + +```toml +[image_optimizer.profile_sets.default_images] +base_params = "quality=70&resize-filter=bicubic" +default_profile = "default" +unknown_profile = "use_default" +profile_param = "profile" +aspect_ratio_param = "ar" +debug_param = "_io_debug" + +[image_optimizer.profile_sets.default_images.profiles] +default = "width=1920" +medium = "format=auto&width=828" +thumbnail = "width=150&crop=1:1,smart" +``` + +Supported profile parameters are `quality`, `resize-filter`, `format`, `width`, `height`, and `crop`. Unknown profile parameters fail configuration validation. + +### `[image_optimizer.profile_sets..aspect_ratios]` + +| Field | Type | Required | Description | +| ---------- | ------------- | -------- | -------------------------------------------- | +| `allowed` | Array[String] | No | Allowed query values such as `1-1` or `16-9` | +| `profiles` | Array[String] | No | Profiles that accept aspect-ratio overrides | + +```toml +[image_optimizer.profile_sets.default_images.aspect_ratios] +allowed = ["1-1", "16-9", "4-3"] +profiles = ["medium", "large"] +``` + +### `[image_optimizer.profile_sets..crop_offsets]` + +| Field | Type | Required | Default | Description | +| -------------- | -------------- | -------- | ---------------------- | -------------------------------------------- | +| `enabled` | Boolean | No | `true` | Enable offset bucketing | +| `x_param` | String | No | `x` | Query parameter for x-axis offset | +| `y_param` | String | No | `y` | Query parameter for y-axis offset | +| `buckets` | Array[Integer] | No | `[10, 30, 50, 70, 90]` | Offset buckets in `0..=100` | +| `default` | Integer | No | `50` | Offset used when input is missing or invalid | +| `when_missing` | String | No | `smart` | `smart` or `none` when neither offset exists | + +```toml +[image_optimizer.profile_sets.default_images.crop_offsets] +enabled = true +x_param = "x" +y_param = "y" +buckets = [10, 30, 50, 70, 90] +default = 50 +when_missing = "smart" +``` + +See [Asset Routes](/guide/asset-routes) for request flow, S3 auth details, and Image Optimizer behavior. + ## Integration Configurations Settings for built-in integrations (Prebid, Next.js, Permutive, Testlight). For other diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index b978e35f..024ab5b9 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -25,10 +25,12 @@ flowchart TD ## Core Endpoints -### `/first-party/proxy` - Asset Proxy +### `/first-party/proxy` - Signed Asset Proxy Proxies third-party assets with automatic HTML/CSS rewriting. +For publisher-owned asset paths that should route directly to a configured origin without `tstoken`, use [Asset Routes](/guide/asset-routes). Asset routes support path-prefix matching, private S3 origins, and Fastly Image Optimizer profile tables. + **Request**: ``` @@ -425,6 +427,18 @@ proxy_secret = "your-secure-random-secret" cookie_domain = ".publisher.com" # For ts-ec cookies ``` +### Asset Routes + +Use `[[proxy.asset_routes]]` when a first-party path prefix should proxy directly to another asset origin. + +```toml +[[proxy.asset_routes]] +prefix = "/assets/" +origin_url = "https://assets.example.com" +``` + +Asset routes are intended for publisher-owned paths, not third-party creative URLs. They support optional path rewrites, private S3 origin signing, and Fastly Image Optimizer metadata. See [Asset Routes](/guide/asset-routes) for full configuration and operational guidance. + ### Proxy Allowlist Restrict which domains the proxy may redirect to via the `[proxy]` section: diff --git a/docs/superpowers/specs/2026-05-19-asset-s3-auth-fastly-io-design.md b/docs/superpowers/specs/2026-05-19-asset-s3-auth-fastly-io-design.md new file mode 100644 index 00000000..75c1559d --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-asset-s3-auth-fastly-io-design.md @@ -0,0 +1,488 @@ +# Asset S3 Auth and Fastly IO Design + +## Problem + +Issue #695 requires authenticated S3 bucket support for path-based asset +proxying. The asset proxy work in #668 routes selected first-party asset paths +to alternate origins, but private S3 buckets return `403` unless upstream +requests are signed. + +Issue #696 requires Fastly Image Optimizer (IO) support for those image asset +routes. These concerns must compose without tightly coupling the asset proxy to +S3, Fastly IO, or any customer-specific URL/profile convention. + +The migration target includes production-style image URLs such as: + +```text +/.image//.jpg?profile=w828&ar=1-1 +``` + +where query parameters describe image transformation intent, not the S3 object +identity. For private S3 + IO, the origin request usually needs to be signed for +the final object path with origin query parameters stripped. + +## Goals + +- Support private S3 buckets as asset route origins using AWS Signature Version + 4 header authentication. +- Keep asset routes generic: routes may use no auth, S3 auth, public S3, a CDN, + Fastly IO, or no IO in any combination. +- Keep S3 signing platform-neutral and independent from Fastly APIs. +- Keep Fastly IO platform-neutral at the core boundary; only the Fastly adapter + should translate to `fastly::image_optimizer::ImageOptimizerOptions`. +- Support configurable profile-table image transformations equivalent to common + VCL table-based IO setups. +- Avoid committing customer-specific names, profile sets, or URL semantics. +- Reuse existing path-prefix and path rewrite behavior from asset routes; S3 + signing signs the final rewritten origin URL. +- Fail fast on invalid configuration where possible. + +## Non-Goals + +- Full AWS SDK integration or AWS default credential-provider support. +- Uploads or signed request bodies. Initial support is read-only `GET` and + `HEAD` asset access. +- Presigned S3 URLs in query parameters. +- Arbitrary IO query DSL support. Initial profile-table values support a strict + subset of IO params. +- Legacy path-parameter normalization such as + `/.image/w_828,ar_1:1//`. Query-profile URLs are handled first; + legacy path normalization can be added later if traffic requires it. +- Local end-to-end IO transformation verification. Viceroy currently does not + perform real image optimization. + +## Proposed Configuration Model + +### Asset route capabilities + +Asset route auth and image optimization are independent optional capabilities: + +```toml +[[proxy.asset_routes]] +prefix = "/.image/" +origin_url = "https://bucket.s3.us-east-1.amazonaws.com" +# Existing #668 path rewrite behavior remains available and unchanged. +# path_pattern = "^/\\.image/(.*)$" +# target_path = "/images/$1" + +[proxy.asset_routes.auth] +type = "s3_sigv4" +region = "us-east-1" +origin_query = "strip" +# Optional secret lookup overrides; these default if omitted. +# secret_store = "s3-auth" +# access_key_id = "access_key_id" +# secret_access_key = "secret_access_key" +# session_token = "session_token" + +[proxy.asset_routes.image_optimizer] +enabled = true +region = "us_east" +profile_set = "default_images" +``` + +Other supported combinations: + +```toml +# Public non-IO asset origin. +[[proxy.asset_routes]] +prefix = "/static/" +origin_url = "https://cdn.example.com" + +# Private S3, no IO. +[[proxy.asset_routes]] +prefix = "/private-assets/" +origin_url = "https://bucket.s3.us-east-1.amazonaws.com" +auth = { type = "s3_sigv4", region = "us-east-1" } + +# Public image origin with IO. +[[proxy.asset_routes]] +prefix = "/images/" +origin_url = "https://images.example.com" +image_optimizer = { enabled = true, region = "us_east", profile_set = "default_images" } +``` + +### S3 auth defaults + +`auth.type = "s3_sigv4"` uses route-scoped config with default secret names: + +| Field | Default | Meaning | +| ------------------- | -------------------------------------- | ------------------------------------------------ | +| `secret_store` | `s3-auth` | Runtime secret store name | +| `access_key_id` | `access_key_id` | Secret key containing AWS access key ID | +| `secret_access_key` | `secret_access_key` | Secret key containing AWS secret access key | +| `session_token` | `session_token` | Optional secret key containing AWS session token | +| `origin_query` | `preserve` without IO, `strip` with IO | Query behavior for the origin/S3 request | + +Credential fields name secret-store entries, not literal credential values. + +### Image optimizer profile sets + +Profile tables are reusable and defined globally in `trusted-server.toml`: + +```toml +[image_optimizer.profile_sets.default_images] +base_params = "quality=70&resize-filter=bicubic" +default_profile = "default" +unknown_profile = "use_default" # "use_default" | "reject" +profile_param = "profile" +aspect_ratio_param = "ar" +debug_param = "_io_debug" + +[image_optimizer.profile_sets.default_images.profiles] +default = "width=1920" +thumbnail = "width=150&crop=1:1,smart" +medium = "format=auto&width=828" +large = "format=auto&width=1536" + +[image_optimizer.profile_sets.default_images.aspect_ratios] +allowed = ["1-1", "16-9", "4-3"] +profiles = ["medium", "large"] +default_crop_mode = "smart" + +[image_optimizer.profile_sets.default_images.crop_offsets] +enabled = true +x_param = "x" +y_param = "y" +buckets = [10, 30, 50, 70, 90] +default = 50 +when_missing = "smart" +``` + +Only a small generic commented example should be committed to +`trusted-server.toml`. Customer-specific profile set names and tables belong in +private deployment configuration or environment overrides. + +## Runtime Semantics + +### Route handling order + +Asset routes are evaluated after explicit built-in and integration routes and +before publisher fallback, as designed in #668. Only `GET` and `HEAD` requests +participate. + +### Asset request pipeline + +For a matched asset route: + +```text +incoming request + -> build final origin URL using existing path_pattern/target_path behavior + -> evaluate image_optimizer profile table from the original client query + -> determine origin query policy + -> build platform-neutral upstream request + -> apply origin auth, if configured + -> attach platform-neutral IO metadata, if enabled and not debug-bypassed + -> PlatformHttpClient::send() +``` + +S3 signing happens after path rewrite and after origin query policy is applied, +so the signature always covers the exact URL that the origin should see. + +### Origin query policy + +`origin_query` controls whether the origin request includes the inbound query: + +- `strip`: origin URL query is empty. +- `preserve`: origin URL query preserves the inbound query string. + +For S3-authenticated routes with IO enabled, default to `strip`. This matches +the common shape where `profile`, `ar`, `x`, and `y` are transformation inputs +rather than S3 object identity. + +Initial implementation rejects `origin_query = "preserve"` when IO is enabled. +Fastly treats request query parameters as possible IO transformation inputs, so +preserving arbitrary client query parameters would bypass the closed profile set +and can also change the URL Fastly ultimately sends to the origin. A future +explicit allowlist can relax this if origin query preservation is needed. + +### Debug bypass + +If the configured `debug_param` is present with value `1`, image optimization is +disabled for that request. The asset route still applies, and origin auth still +applies if configured. The response is the original source object. + +This provides a simple way to compare optimized vs. origin images. + +## S3 SigV4 Auth + +### Signing method + +Use header-based AWS Signature Version 4. Do not generate presigned query URLs. + +For `GET` and `HEAD`, add or overwrite: + +```http +Host: +x-amz-date: +x-amz-content-sha256: UNSIGNED-PAYLOAD +x-amz-security-token: # only when configured and present +Authorization: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=... +``` + +Use: + +- service: `s3` +- payload hash: `UNSIGNED-PAYLOAD` +- method: `GET` or `HEAD` +- canonical URI/query from the final origin URL +- canonical headers including at least `host`, `x-amz-content-sha256`, + `x-amz-date`, and `x-amz-security-token` when present + +### Credential loading + +Read credentials from `RuntimeServices::secret_store()` using the configured +runtime `StoreName`. The core signer receives strings/bytes and does not depend +on Fastly Secret Store directly. + +Missing access key or secret key is a request-time proxy error. Missing session +token is allowed when `session_token` is absent or when the optional configured +secret is not present, depending on implementation ergonomics. Do not log secret +values. + +### Endpoint assumptions + +For `s3_sigv4`, `origin_url` must be the real S3 or S3-compatible endpoint host +that the origin will validate in the signature, for example: + +```toml +origin_url = "https://my-bucket.s3.us-east-1.amazonaws.com" +``` + +or path-style/S3-compatible usage: + +```toml +origin_url = "https://s3.us-east-1.amazonaws.com" +path_pattern = "^/\\.image/(.*)$" +target_path = "/my-bucket/$1" +``` + +Do not sign for one host while sending to a different vanity host. + +## Platform-Neutral Image Optimizer Metadata + +Extend the platform HTTP abstraction so core can request image optimization +without importing Fastly SDK types. + +Suggested core shape: + +```rust +pub struct PlatformHttpRequest { + pub request: EdgeRequest, + pub backend_name: String, + pub image_optimizer: Option, +} + +pub struct PlatformImageOptimizerOptions { + pub region: PlatformImageOptimizerRegion, + pub preserve_query_string_on_origin_request: bool, + pub params: PlatformImageOptimizerParams, +} +``` + +`PlatformImageOptimizerParams` initially needs only the strict subset produced +by profile tables: + +- `quality` +- `resize_filter` +- `format` +- `width` +- `height` +- `crop` + +The Fastly adapter maps these neutral options to +`fastly::image_optimizer::ImageOptimizerOptions` and calls +`Request::set_image_optimizer()` in `send()`. + +`FastlyPlatformHttpClient::send_async()` must reject requests that carry image +optimizer metadata because the Fastly Rust SDK does not support IO with +`send_async` or `send_async_streaming`. + +Test platform clients should record the metadata so core tests can assert that +IO would be requested without needing real Fastly IO locally. + +## Profile Table Conversion + +### Supported profile parameters + +The profile parser is intentionally strict in v1. Profile strings may contain: + +- `quality=<0..100>` +- `resize-filter=bicubic` initially, plus any Fastly SDK resize filters we map +- `format=auto|avif|gif|jpeg|jxl|mp4|png|webp` as supported by the SDK/version +- `width=` +- `height=` +- `crop=:` +- `crop=:,smart` +- `crop=:,offset-x,offset-y` as generated by offset handling + +Unknown keys or invalid values fail config preparation. + +### Profile lookup + +For a request query: + +```text +?profile=medium&ar=1-1&x=70&y=30 +``` + +1. Read `profile_param` from the query. +2. Look up the profile in the configured profile set. +3. If missing/unknown and `unknown_profile = "use_default"`, use + `default_profile`. +4. If missing/unknown and `unknown_profile = "reject"`, return a bad request or + proxy error before sending upstream. +5. Merge `base_params` and the selected profile params, with profile params + overriding base params if the same key appears. + +### Aspect ratio override + +If `aspect_ratio_param` is present and valid: + +- the value must be in `aspect_ratios.allowed` +- the selected profile must be in `aspect_ratios.profiles` + +Then set/replace `crop` to the requested aspect ratio. Convert `1-1` to `1:1`. +Invalid or unsupported aspect ratio values are ignored to match tolerant VCL +behavior and avoid breaking images. + +### Crop offset bucketing + +When crop offsets are enabled and the final crop is a bare aspect ratio: + +- if either `x_param` or `y_param` is present, normalize each value to the + configured bucket list and append `offset-xN,offset-yN` +- invalid, missing, non-numeric, or out-of-range values normalize to the + configured default +- if neither offset is present and `when_missing = "smart"`, append `smart` + +Default bucket behavior should match the common VCL pattern: + +```text +<20 => 10 +<40 => 30 +<60 => 50 +<80 => 70 +else => 90 +``` + +The configured bucket list must be sorted and bounded to a safe range to avoid +variant explosion. + +### Closed output set + +The profile mapper consumes `profile`, `ar`, `x`, and `y`. It does not pass the +client query through as raw IO params. The generated IO metadata is the only +transformation request, unless debug bypass disables IO. + +## Local Development and Verification + +Local development can validate: + +- config parsing and validation +- asset route matching and path rewrite behavior +- profile table conversion +- S3 SigV4 canonical request/signature generation +- platform-neutral IO metadata attachment +- Fastly adapter rejection of IO metadata on async sends + +Local Viceroy cannot prove real image resizing/format conversion. Current +Viceroy source returns unsupported for the Image Optimizer hostcall. End-to-end +acceptance requires a Fastly Compute service with IO enabled for the account and +service. + +## Testing Strategy + +### Settings tests + +- parse `auth = { type = "s3_sigv4", ... }` +- default S3 secret store/key names +- reject invalid S3 region/empty credential key names +- parse top-level `image_optimizer.profile_sets` +- reject route `image_optimizer.profile_set` references that do not exist +- reject unsupported profile-table params +- reject invalid crop/quality/width/height values +- accept generic commented-example-equivalent config + +### Profile conversion tests + +- missing profile uses default profile +- unknown profile uses default when configured +- unknown profile rejects when configured +- `profile=medium` produces base + profile IO params +- valid aspect ratio override appends/replaces crop only for configured profiles +- invalid aspect ratio is ignored +- no offsets appends `smart` for bare crops +- `x`/`y` offsets normalize to configured buckets +- debug param disables IO metadata +- arbitrary query params are not passed into generated IO metadata + +### S3 signer tests + +- canonical request uses final rewritten path +- origin query is stripped when configured +- origin query is preserved when configured +- generated `Authorization` matches a stable fixture +- session token is added and signed when present +- unsigned payload header is present +- inbound `Authorization` and `x-amz-*` headers are overwritten/not trusted +- secret values are not logged or surfaced in error text + +### Asset proxy tests + +- public asset route still works without auth or IO +- S3 auth route signs before sending +- IO route attaches platform-neutral metadata +- S3 + IO route strips origin query by default +- S3 + IO rejects preserve-query configuration while profile-table IO is enabled +- path rewrite occurs before signing +- `GET` and `HEAD` are supported +- non-`GET`/`HEAD` skip asset route and fall through as existing behavior +- upstream `Set-Cookie` and HSTS stripping remains unchanged from #668 + +### Fastly adapter tests + +- `send()` maps neutral IO options to Fastly SDK options +- `send_async()` fails when IO metadata is present +- request headers from S3 signing survive Fastly request conversion +- Host/backend behavior remains consistent with signed host expectations + +## Implementation Plan + +1. Add config structs for: + - `AssetOriginAuth` + - `S3SigV4AuthConfig` + - top-level `ImageOptimizerSettings` + - `ImageOptimizerProfileSet` + - route-level `AssetImageOptimizerConfig` +2. Add profile-set normalization and validation during settings preparation. +3. Extend `PlatformHttpRequest` with optional `PlatformImageOptimizerOptions`. +4. Add Fastly adapter mapping for neutral IO metadata in `send()` and explicit + rejection in `send_async()`. +5. Implement strict profile-table parser/converter. +6. Implement internal lightweight S3 SigV4 signer using existing crypto crates. +7. Wire asset route handling: + - build final origin URL using existing rewrite behavior + - compute IO metadata or debug bypass + - apply origin query policy + - build outbound request + - apply S3 signing if configured + - send synchronously +8. Add generic commented config examples to `trusted-server.toml`. +9. Run: + - `cargo test --workspace` + - `cargo fmt --all -- --check` + - `cargo clippy --workspace --all-targets --all-features -- -D warnings` + - wasm build for `trusted-server-adapter-fastly` + +## Open Questions + +- What exact S3 object key mapping will production deployments need? Keep this + configurable through the existing path rewrite fields. +- Should missing optional `session_token` be silently ignored or fail if the + config names a token secret that does not exist? Recommended: ignore only when + using the default optional key, fail if explicitly configured. +- Which Fastly IO region should production configs use? It should be close to + the S3/image origin. +- Should a later phase add legacy path-parameter normalization? Defer until + active traffic requires it. diff --git a/trusted-server.toml b/trusted-server.toml index 97bccbd5..f6efc3eb 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -160,6 +160,36 @@ rewrite_script = true # "*.edgecompute.app", # ] +# Reusable Fastly Image Optimizer profile sets for asset routes. +# Keep production/customer-specific profile names and tables in private deployment config. +# Profile values intentionally support a strict subset of IO params: quality, +# resize-filter, format, width, height, and crop. Client query parameters are +# mapped through this table instead of being passed through as arbitrary IO options. +# [image_optimizer.profile_sets.default_images] +# base_params = "quality=70&resize-filter=bicubic" +# default_profile = "default" +# unknown_profile = "use_default" # "use_default" or "reject" +# profile_param = "profile" +# aspect_ratio_param = "ar" +# debug_param = "_io_debug" # _io_debug=1 bypasses IO for one request +# +# [image_optimizer.profile_sets.default_images.profiles] +# default = "width=1920" +# thumbnail = "width=150&crop=1:1,smart" +# medium = "format=auto&width=828" +# large = "format=auto&width=1536" +# +# [image_optimizer.profile_sets.default_images.aspect_ratios] +# allowed = ["1-1", "16-9", "4-3"] +# profiles = ["medium", "large"] +# +# [image_optimizer.profile_sets.default_images.crop_offsets] +# enabled = true +# x_param = "x" +# y_param = "y" +# buckets = [10, 30, 50, 70, 90] +# default = 50 +# when_missing = "smart" # Proxy configuration [proxy] @@ -179,6 +209,27 @@ rewrite_script = true # prefix = "/.images/" # origin_url = "https://some.fastly-service.com" # +# Example: private S3 origin with Fastly IO profile-table conversion. +# [[proxy.asset_routes]] +# prefix = "/.image/" +# origin_url = "https://bucket.s3.us-east-1.amazonaws.com" +# +# [proxy.asset_routes.auth] +# type = "s3_sigv4" +# region = "us-east-1" +# origin_query = "strip" # Strip transform query params before S3 signing +# secret_store = "s3-auth" +# access_key_id = "access_key_id" +# secret_access_key = "secret_access_key" +# # session_token = "session_token" +# +# [proxy.asset_routes.image_optimizer] +# enabled = true +# region = "us_east" +# profile_set = "default_images" +# # Enabled IO routes strip origin queries by default. origin_query = "preserve" +# # is rejected while IO is enabled because Fastly can treat query params as transforms. +# # Example: CDN-style first-party image path rewrite. # [[proxy.asset_routes]] # prefix = "/.image/" From eece80af4d9c9340bcc3bc4f4979c96eedae9ce9 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 21 May 2026 10:03:46 -0500 Subject: [PATCH 4/4] Accept env bools for image optimizer settings --- crates/trusted-server-core/src/settings.rs | 135 ++++++++++++++++++++- 1 file changed, 133 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 1e4d1bcf..603d842e 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -496,7 +496,10 @@ impl S3SigV4AuthConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AssetImageOptimizerConfig { /// Enables Image Optimizer for this route when the table is present. - #[serde(default = "default_asset_image_optimizer_enabled")] + #[serde( + default = "default_asset_image_optimizer_enabled", + deserialize_with = "bool_from_bool_or_str" + )] pub enabled: bool, /// Image Optimizer processing region. pub region: String, @@ -730,7 +733,10 @@ pub enum MissingCropOffsetMode { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ImageOptimizerCropOffsetsConfig { /// Enable crop offset normalization. - #[serde(default = "default_asset_image_optimizer_enabled")] + #[serde( + default = "default_asset_image_optimizer_enabled", + deserialize_with = "bool_from_bool_or_str" + )] pub enabled: bool, /// Query parameter containing the x-axis offset. #[serde(default = "default_crop_offset_x_param")] @@ -1706,6 +1712,23 @@ where } } +pub(crate) fn bool_from_bool_or_str<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = JsonValue::deserialize(deserializer)?; + match value { + JsonValue::Bool(value) => Ok(value), + JsonValue::String(value) => value + .trim() + .parse::() + .map_err(serde::de::Error::custom), + other => Err(serde::de::Error::custom(format!( + "expected bool or parseable bool string, got {other}" + ))), + } +} + pub(crate) fn vec_from_seq_or_map<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -2993,6 +3016,114 @@ mod tests { } } + #[test] + fn proxy_asset_route_image_optimizer_env_accepts_nested_bool_strings_and_arrays() { + let toml_str = crate_test_settings_str(); + let separator = ENVIRONMENT_VARIABLE_SEPARATOR; + let vars = [ + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}PROXY{separator}ASSET_ROUTES{separator}0{separator}PREFIX"), + Some("/.image/"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}PROXY{separator}ASSET_ROUTES{separator}0{separator}ORIGIN_URL"), + Some("https://bucket.s3.us-west-2.amazonaws.com"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}PROXY{separator}ASSET_ROUTES{separator}0{separator}AUTH{separator}TYPE"), + Some("s3_sigv4"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}PROXY{separator}ASSET_ROUTES{separator}0{separator}AUTH{separator}REGION"), + Some("us-west-2"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}PROXY{separator}ASSET_ROUTES{separator}0{separator}AUTH{separator}ORIGIN_QUERY"), + Some("strip"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}PROXY{separator}ASSET_ROUTES{separator}0{separator}IMAGE_OPTIMIZER{separator}ENABLED"), + Some("true"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}PROXY{separator}ASSET_ROUTES{separator}0{separator}IMAGE_OPTIMIZER{separator}REGION"), + Some("us_west"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}PROXY{separator}ASSET_ROUTES{separator}0{separator}IMAGE_OPTIMIZER{separator}PROFILE_SET"), + Some("default_images"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}IMAGE_OPTIMIZER{separator}PROFILE_SETS{separator}DEFAULT_IMAGES{separator}BASE_PARAMS"), + Some("quality=70&resize-filter=bicubic"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}IMAGE_OPTIMIZER{separator}PROFILE_SETS{separator}DEFAULT_IMAGES{separator}DEFAULT_PROFILE"), + Some("w828"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}IMAGE_OPTIMIZER{separator}PROFILE_SETS{separator}DEFAULT_IMAGES{separator}PROFILES{separator}W828"), + Some("format=auto&width=828"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}IMAGE_OPTIMIZER{separator}PROFILE_SETS{separator}DEFAULT_IMAGES{separator}PROFILES{separator}W1536"), + Some("format=auto&width=1536"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}IMAGE_OPTIMIZER{separator}PROFILE_SETS{separator}DEFAULT_IMAGES{separator}ASPECT_RATIOS{separator}ALLOWED"), + Some("[\"1-1\",\"16-9\"]"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}IMAGE_OPTIMIZER{separator}PROFILE_SETS{separator}DEFAULT_IMAGES{separator}ASPECT_RATIOS{separator}PROFILES"), + Some("[\"w828\",\"w1536\"]"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}IMAGE_OPTIMIZER{separator}PROFILE_SETS{separator}DEFAULT_IMAGES{separator}CROP_OFFSETS{separator}ENABLED"), + Some("true"), + ), + ( + format!("{ENVIRONMENT_VARIABLE_PREFIX}{separator}IMAGE_OPTIMIZER{separator}PROFILE_SETS{separator}DEFAULT_IMAGES{separator}CROP_OFFSETS{separator}BUCKETS"), + Some("[10,30,50,70,90]"), + ), + ]; + + temp_env::with_vars(vars, || { + let settings = Settings::from_toml_and_env(&toml_str) + .expect("should parse image optimizer env overrides"); + let route = settings + .asset_route_for_path("/.image/id/example.jpg") + .expect("should match image optimizer asset route"); + assert!(route.image_optimizer_enabled()); + + let image_optimizer = route + .image_optimizer + .as_ref() + .expect("should configure image optimizer"); + assert!(image_optimizer.enabled); + assert_eq!(image_optimizer.region, "us_west"); + assert_eq!(image_optimizer.profile_set, "default_images"); + + let profile_set = settings + .image_optimizer + .profile_sets + .get("default_images") + .expect("should configure default image profiles"); + assert_eq!(profile_set.profiles["w828"], "format=auto&width=828"); + let aspect_ratios = profile_set + .aspect_ratios + .as_ref() + .expect("should configure aspect ratios"); + assert_eq!(aspect_ratios.allowed, vec!["1-1", "16-9"]); + assert_eq!(aspect_ratios.profiles, vec!["w828", "w1536"]); + let crop_offsets = profile_set + .crop_offsets + .as_ref() + .expect("should configure crop offsets"); + assert!(crop_offsets.enabled); + assert_eq!(crop_offsets.buckets, vec![10, 30, 50, 70, 90]); + }); + } + #[test] fn proxy_asset_route_validation_rejects_image_optimizer_preserve_query() { let toml_str = crate_test_settings_str()