diff --git a/crates/http-backend/src/lib.rs b/crates/http-backend/src/lib.rs index a2f5d1d..6f1c9b5 100644 --- a/crates/http-backend/src/lib.rs +++ b/crates/http-backend/src/lib.rs @@ -34,6 +34,24 @@ type HeaderNameList = Vec; pub const SERVER_NAME_HEADER: &str = "server_name"; +// Header names used by FastEdge routing — single source of truth so a typo +// can't slip in at one of the several push/filter sites that reference them. +const FASTEDGE_HOSTNAME: &str = "fastedge-hostname"; +const FASTEDGE_SCHEME: &str = "fastedge-scheme"; +// Lowercase form so it matches the lowercased key used in the inbound-header +// filter below; HTTP header names are case-insensitive on the wire. +const FASTEDGE_HEADER_HOSTNAME: &str = "fastedge_header_hostname"; +const HOST_HEADER: &str = "host"; +const CONTENT_LENGTH_HEADER: &str = "content-length"; +const TRANSFER_ENCODING_HEADER: &str = "transfer-encoding"; + +// Default scheme used when an outbound URL doesn't specify one. +const DEFAULT_SCHEME: &str = "http"; + +// Loopback hostname injected into `fastedge-hostname` when self-binding the +// outbound call back to the app itself. +const SELF_BINDING_HOST: &str = "127.0.0.1"; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BackendStrategy { Direct, @@ -69,6 +87,7 @@ pub struct Backend { /// deposits its elapsed wall-clock time (ms) here so the epoch /// deadline callback can refund those ticks to the guest. epoch_pause_ms: Arc, + cdn_real_host: Option, } pub struct Builder { @@ -118,6 +137,7 @@ impl Builder { ext_http_stats: None, hostname: self.hostname.clone(), epoch_pause_ms: Arc::new(AtomicU64::new(0)), + cdn_real_host: None, } } } @@ -141,6 +161,14 @@ impl Backend { self.hostname.clone() } + pub fn set_cdn_real_host(&mut self, cdn_real_host: SmolStr) { + self.cdn_real_host = Some(cdn_real_host); + } + + pub fn cdn_real_host(&self) -> Option { + self.cdn_real_host.clone() + } + /// Set external request stats pub fn set_ext_http_stats(&mut self, stats: Arc) { self.ext_http_stats.replace(stats); @@ -251,6 +279,21 @@ impl Backend { original_host ); + // When the outbound call targets the CDN "real host" (the host of the + // original end-user request, carried in the `X-Cdn-Real-Host` header) on a + // default port, the backend resource is the app itself. In that case route + // the request back to `localhost`, expose the real host via + // `Fastedge_Header_Hostname`, and ignore any `Host` header the app set. + let self_binding = self + .cdn_real_host + .as_ref() + .zip(original_url.host()) + .is_some_and(|(cdn_real_host, url_host)| { + let port_ok = + matches!(original_url.port_u16(), None | Some(80) | Some(443)); + port_ok && cdn_real_host.eq_ignore_ascii_case(url_host) + }); + // filter headers let mut headers = req .headers @@ -259,11 +302,12 @@ impl Backend { .filter(|(k, _)| { !matches!( k.as_str(), - "host" - | "content-length" - | "transfer-encoding" - | "fastedge-hostname" - | "fastedge-scheme" + HOST_HEADER + | CONTENT_LENGTH_HEADER + | TRANSFER_ENCODING_HEADER + | FASTEDGE_HOSTNAME + | FASTEDGE_SCHEME + | FASTEDGE_HEADER_HOSTNAME ) }) .filter(|(k, _)| { @@ -274,14 +318,31 @@ impl Backend { }) .collect::>(); - headers.push(("fastedge-hostname".to_string(), original_host)); - headers.push(( - "fastedge-scheme".to_string(), - original_url.scheme_str().unwrap_or("http").to_string(), - )); - //When HTTP app sets Host header, Fastegde needs to set Fastedge_Header_Hostname header for BE. - if let Some(request_host_header) = request_host_header { - headers.push(("Fastedge_Header_Hostname".to_string(), request_host_header)); + if self_binding { + // URL host is guaranteed present here (checked above). + let url_host = original_url.host().unwrap_or_default().to_string(); + headers.push((FASTEDGE_HOSTNAME.to_string(), SELF_BINDING_HOST.to_string())); + headers.push(( + FASTEDGE_SCHEME.to_string(), + original_url + .scheme_str() + .unwrap_or(DEFAULT_SCHEME) + .to_string(), + )); + headers.push((FASTEDGE_HEADER_HOSTNAME.to_string(), url_host)); + } else { + headers.push((FASTEDGE_HOSTNAME.to_string(), original_host)); + headers.push(( + FASTEDGE_SCHEME.to_string(), + original_url + .scheme_str() + .unwrap_or(DEFAULT_SCHEME) + .to_string(), + )); + //When HTTP app sets Host header, Fastegde needs to set Fastedge_Header_Hostname header for BE. + if let Some(request_host_header) = request_host_header { + headers.push((FASTEDGE_HEADER_HOSTNAME.to_string(), request_host_header)); + } } headers.extend(self.propagate_headers_vec()); @@ -714,6 +775,178 @@ mod tests { assert_eq!(http::StatusCode::OK, res.status); } + #[tokio::test] + #[tracing_test::traced_test] + async fn cdn_real_host_self_binding() { + let mut builder = mock_http_connector::Connector::builder(); + builder + .expect() + .times(1) + .with_method(http::Method::GET) + .with_uri("http://be.server/path") + .with_header("fastedge-hostname", "127.0.0.1") + .with_header("fastedge-scheme", "http") + .with_header("fastedge_header_hostname", "example.com") + .with_header("host", "be.server") + .with_header("header01", "01") + .returning("OK") + .unwrap(); + let connector = builder.build(); + let mut backend = + Backend::::builder(BackendStrategy::FastEdge) + .hostname("be.server") + .build(connector); + backend.set_cdn_real_host("example.com".into()); + let headers = HeaderMap::new(); + claims::assert_ok!(backend.propagate_headers(headers)); + let req = Request { + method: Method::Get, + uri: "http://example.com/path".to_string(), + headers: vec![("header01".to_string(), "01".to_string())], + body: None, + }; + let res = claims::assert_ok!(backend.send_request(req).await); + assert_eq!(http::StatusCode::OK, res.status); + } + + #[tokio::test] + #[tracing_test::traced_test] + async fn cdn_real_host_self_binding_ignores_app_host_header() { + let mut builder = mock_http_connector::Connector::builder(); + builder + .expect() + .times(1) + .with_method(http::Method::GET) + .with_uri("http://be.server/path") + .with_header("fastedge-hostname", "127.0.0.1") + .with_header("fastedge-scheme", "https") + // app-provided Host header is ignored; the real host comes from the URL + .with_header("fastedge_header_hostname", "example.com") + .with_header("host", "be.server") + .returning("OK") + .unwrap(); + let connector = builder.build(); + let mut backend = + Backend::::builder(BackendStrategy::FastEdge) + .hostname("be.server") + .build(connector); + backend.set_cdn_real_host("example.com".into()); + let headers = HeaderMap::new(); + claims::assert_ok!(backend.propagate_headers(headers)); + let req = Request { + method: Method::Get, + uri: "https://example.com:443/path".to_string(), + headers: vec![("host".to_string(), "app-set-host.com".to_string())], + body: None, + }; + let res = claims::assert_ok!(backend.send_request(req).await); + assert_eq!(http::StatusCode::OK, res.status); + } + + #[tokio::test] + #[tracing_test::traced_test] + async fn cdn_real_host_non_default_port_skips_self_binding() { + let mut builder = mock_http_connector::Connector::builder(); + builder + .expect() + .times(1) + .with_method(http::Method::GET) + .with_uri("http://be.server/path") + // non-default port => behaves like a normal external call + .with_header("fastedge-hostname", "example.com:8080") + .with_header("fastedge-scheme", "http") + .with_header("host", "be.server") + .returning("OK") + .unwrap(); + let connector = builder.build(); + let mut backend = + Backend::::builder(BackendStrategy::FastEdge) + .hostname("be.server") + .build(connector); + backend.set_cdn_real_host("example.com".into()); + let headers = HeaderMap::new(); + claims::assert_ok!(backend.propagate_headers(headers)); + let req = Request { + method: Method::Get, + uri: "http://example.com:8080/path".to_string(), + headers: vec![], + body: None, + }; + let res = claims::assert_ok!(backend.send_request(req).await); + assert_eq!(http::StatusCode::OK, res.status); + } + + #[tokio::test] + #[tracing_test::traced_test] + async fn cdn_real_host_self_binding_strips_spoofed_header_hostname() { + // App tries to spoof the internal `fastedge_header_hostname` routing + // header. The filter must strip it; `with_header_once` asserts only the + // FastEdge-set value reaches the backend (no duplicate/leak). + let mut builder = mock_http_connector::Connector::builder(); + builder + .expect() + .times(1) + .with_method(http::Method::GET) + .with_uri("http://be.server/path") + .with_header("fastedge-hostname", "127.0.0.1") + .with_header("fastedge-scheme", "http") + .with_header_once("fastedge_header_hostname", "example.com") + .with_header("host", "be.server") + .returning("OK") + .unwrap(); + let connector = builder.build(); + let mut backend = + Backend::::builder(BackendStrategy::FastEdge) + .hostname("be.server") + .build(connector); + backend.set_cdn_real_host("example.com".into()); + let headers = HeaderMap::new(); + claims::assert_ok!(backend.propagate_headers(headers)); + let req = Request { + method: Method::Get, + uri: "http://example.com/path".to_string(), + headers: vec![( + "fastedge_header_hostname".to_string(), + "evil.example.com".to_string(), + )], + body: None, + }; + let res = claims::assert_ok!(backend.send_request(req).await); + assert_eq!(http::StatusCode::OK, res.status); + } + + #[tokio::test] + #[tracing_test::traced_test] + async fn cdn_real_host_mismatch_skips_self_binding() { + let mut builder = mock_http_connector::Connector::builder(); + builder + .expect() + .times(1) + .with_method(http::Method::GET) + .with_uri("http://be.server/path") + .with_header("fastedge-hostname", "example.com") + .with_header("fastedge-scheme", "http") + .with_header("host", "be.server") + .returning("OK") + .unwrap(); + let connector = builder.build(); + let mut backend = + Backend::::builder(BackendStrategy::FastEdge) + .hostname("be.server") + .build(connector); + backend.set_cdn_real_host("other.com".into()); + let headers = HeaderMap::new(); + claims::assert_ok!(backend.propagate_headers(headers)); + let req = Request { + method: Method::Get, + uri: "http://example.com/path".to_string(), + headers: vec![], + body: None, + }; + let res = claims::assert_ok!(backend.send_request(req).await); + assert_eq!(http::StatusCode::OK, res.status); + } + #[tokio::test] #[tracing_test::traced_test] async fn filter_headers() { @@ -745,6 +978,11 @@ mod tests { ("Transfer-Encoding".to_string(), "unexpected".to_string()), ("fastedge-hostname".to_string(), "unexpected".to_string()), ("fastedge-scheme".to_string(), "unexpected".to_string()), + // App tries to spoof the internal real-host routing header. + ( + "fastedge_header_hostname".to_string(), + "evil.example.com".to_string(), + ), ], body: None, }; diff --git a/crates/http-service/src/executor/http.rs b/crates/http-service/src/executor/http.rs index 26388dd..1d00a46 100644 --- a/crates/http-service/src/executor/http.rs +++ b/crates/http-service/src/executor/http.rs @@ -90,6 +90,14 @@ where http_backend.set_ext_http_stats(stats.clone()); http_backend.set_epoch_pause_ms(epoch_pause_ms); + if let Some(cdn_real_host) = parts + .headers + .get(executor::X_CDN_REAL_HOST) + .and_then(|v| v.to_str().ok()) + { + http_backend.set_cdn_real_host(cdn_real_host.into()); + } + let propagate_header_names = http_backend.propagate_header_names(); let backend_uri = http_backend.uri(); let state = HttpState { diff --git a/crates/http-service/src/executor/mod.rs b/crates/http-service/src/executor/mod.rs index 7160bae..1d446fb 100644 --- a/crates/http-service/src/executor/mod.rs +++ b/crates/http-service/src/executor/mod.rs @@ -20,6 +20,7 @@ pub use wasi_http::WasiHttpExecutorImpl; pub(crate) static X_REAL_IP: &str = "x-real-ip"; pub(crate) static TRACEPARENT: &str = "traceparent"; pub(crate) static X_CDN_REQUESTOR: &str = "x-cdn-requestor"; +pub(crate) static X_CDN_REAL_HOST: &str = "x-cdn-real-host"; #[async_trait] pub trait HttpExecutor { diff --git a/crates/http-service/src/executor/wasi_http.rs b/crates/http-service/src/executor/wasi_http.rs index 9ca73cb..3638f40 100644 --- a/crates/http-service/src/executor/wasi_http.rs +++ b/crates/http-service/src/executor/wasi_http.rs @@ -100,6 +100,14 @@ where let mut http_backend = self.backend; http_backend.set_epoch_pause_ms(epoch_pause_ms); + if let Some(cdn_real_host) = parts + .headers + .get(executor::X_CDN_REAL_HOST) + .and_then(|v| v.to_str().ok()) + { + http_backend.set_cdn_real_host(cdn_real_host.into()); + } + http_backend .propagate_headers(parts.headers.clone()) .context("propagate headers")?; diff --git a/crates/http-service/src/state.rs b/crates/http-service/src/state.rs index c97b956..cd708e6 100644 --- a/crates/http-service/src/state.rs +++ b/crates/http-service/src/state.rs @@ -1,7 +1,7 @@ use anyhow::Error; use http::request::Parts; use http::uri::Scheme; -use http::{HeaderMap, HeaderName, Uri, header}; +use http::{HeaderMap, HeaderName, HeaderValue, Uri, header}; use http_backend::Backend; use http_backend::is_public_host; use runtime::BackendRequest; @@ -18,7 +18,16 @@ pub struct HttpState { pub(super) stats: Arc, } -const FASTEDGE_HEADER_HOSTNAME: &[u8] = b"Fastedge_Header_Hostname"; +// `HeaderName::from_static` is `const fn`, so the routing-header names can be +// declared once at module scope and reused everywhere without re-parsing. +const FASTEDGE_HOSTNAME: HeaderName = HeaderName::from_static("fastedge-hostname"); +const FASTEDGE_SCHEME: HeaderName = HeaderName::from_static("fastedge-scheme"); +// NOTE: `HeaderName::from_static` requires the input to be lowercase; the +// previous code preserved the mixed-case `Fastedge_Header_Hostname` purely as +// the display form, but `HeaderMap` lookups are case-insensitive so the +// lowercase form is equivalent on the wire. +const FASTEDGE_HEADER_HOSTNAME: HeaderName = HeaderName::from_static("fastedge_header_hostname"); +const FASTEDGE_HOSTNAME_VALUE: HeaderValue = HeaderValue::from_static("127.0.0.1"); impl BackendRequest for HttpState { #[instrument(skip(self, head), level = "debug", ret)] @@ -64,13 +73,32 @@ impl BackendRequest for HttpState { original_host ); - static FILTER_HEADERS: [HeaderName; 6] = [ + // When the outbound call targets the CDN "real host" (the host of the + // original end-user request, carried in the `X-Cdn-Real-Host` header) on a + // default port, the backend resource is the app itself. In that case route + // the request back to `localhost`, expose the real host via + // `Fastedge_Header_Hostname`, and ignore any `Host` header the app set. + let self_binding = self + .http_backend + .cdn_real_host() + .zip(original_url.host()) + .is_some_and(|(cdn_real_host, url_host)| { + let port_ok = + matches!(original_url.port_u16(), None | Some(80) | Some(443)); + port_ok && cdn_real_host.eq_ignore_ascii_case(url_host) + }); + + // `FASTEDGE_HEADER_HOSTNAME` is an internal routing header set by + // FastEdge below; strip any app-provided value so a guest can't spoof + // the "real host" seen by the backend. + static FILTER_HEADERS: [HeaderName; 7] = [ header::HOST, header::CONTENT_LENGTH, header::TRANSFER_ENCODING, header::UPGRADE, - HeaderName::from_static("fastedge-hostname"), - HeaderName::from_static("fastedge-scheme"), + FASTEDGE_HOSTNAME, + FASTEDGE_SCHEME, + FASTEDGE_HEADER_HOSTNAME, ]; // filter headers @@ -83,19 +111,28 @@ impl BackendRequest for HttpState { }) .collect::(); - headers.insert( - HeaderName::from_static("fastedge-hostname"), - original_host.parse()?, - ); - headers.insert( - HeaderName::from_static("fastedge-scheme"), - original_url.scheme_str().unwrap_or("http").parse()?, - ); - //When HTTP app sets Host header, Fastegde needs to set Fastedge_Header_Hostname header for BE. - if let Some(request_host_header) = request_host_header_value { - let fastedge_header_hostname = - HeaderName::from_bytes(FASTEDGE_HEADER_HOSTNAME)?; - headers.insert(fastedge_header_hostname, request_host_header); + if self_binding { + tracing::debug!( + "found backend request original url host matches cdn real host, applying self-binding logic" + ); + // URL host is guaranteed present here (checked above). + let url_host = original_url.host().unwrap_or_default(); + headers.insert(FASTEDGE_HOSTNAME, FASTEDGE_HOSTNAME_VALUE); + headers.insert( + FASTEDGE_SCHEME, + original_url.scheme_str().unwrap_or("http").parse()?, + ); + headers.insert(FASTEDGE_HEADER_HOSTNAME, url_host.parse()?); + } else { + headers.insert(FASTEDGE_HOSTNAME, original_host.parse()?); + headers.insert( + FASTEDGE_SCHEME, + original_url.scheme_str().unwrap_or("http").parse()?, + ); + //When HTTP app sets Host header, Fastegde needs to set Fastedge_Header_Hostname header for BE. + if let Some(request_host_header) = request_host_header_value { + headers.insert(FASTEDGE_HEADER_HOSTNAME, request_host_header); + } } headers.extend(self.propagate_headers.clone()); @@ -159,3 +196,166 @@ impl HasStats for HttpState { self.stats.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + use http::Request; + use http_backend::stats::ExtRequestStats; + use http_backend::{BackendStrategy, FastEdgeConnector}; + use key_value_store::ReadStats; + use runtime::util::stats::CdnPhase; + use std::time::Duration; + use utils::UserDiagStats; + + #[derive(Clone)] + struct TestStats; + impl ReadStats for TestStats { + fn count_kv_read(&self, _value: i32) {} + fn count_kv_byod_read(&self, _value: i32) {} + } + impl UserDiagStats for TestStats { + fn set_user_diag(&self, _diag: &str) {} + } + impl ExtRequestStats for TestStats { + fn observe_ext(&self, _elapsed: Duration) {} + } + impl StatsVisitor for TestStats { + fn status_code(&self, _status_code: u16) {} + fn memory_used(&self, _memory_used: u64) {} + fn fail_reason(&self, _fail_reason: i32) {} + fn observe(&self, _elapsed: Duration) {} + fn get_time_elapsed(&self) -> u64 { + 0 + } + fn get_memory_used(&self) -> u64 { + 0 + } + fn cdn_phase(&self, _phase: CdnPhase) {} + } + + fn make_state(cdn_real_host: Option<&str>) -> HttpState { + let connector = FastEdgeConnector::new("http://be.server/".parse().unwrap()); + let mut http_backend = Backend::::builder(BackendStrategy::FastEdge) + .hostname("be.server") + .uri("http://be.server/".parse().unwrap()) + .build(connector); + if let Some(cdn_real_host) = cdn_real_host { + http_backend.set_cdn_real_host(cdn_real_host.into()); + } + let backend_uri = http_backend.uri(); + HttpState { + http_backend, + uri: backend_uri, + propagate_headers: HeaderMap::new(), + propagate_header_names: vec![], + stats: Arc::new(TestStats), + } + } + + fn parts(uri: &str, headers: &[(&str, &str)]) -> Parts { + let mut builder = Request::builder().uri(uri); + for (k, v) in headers { + builder = builder.header(*k, *v); + } + builder.body(()).unwrap().into_parts().0 + } + + fn header<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> { + parts.headers.get(name).and_then(|v| v.to_str().ok()) + } + + #[test] + fn self_binding_rewrites_routing_headers() { + let mut state = make_state(Some("example.com")); + let out = state + .backend_request(parts("http://example.com/path", &[])) + .unwrap(); + assert_eq!(header(&out, "fastedge-hostname"), Some("127.0.0.1")); + assert_eq!(header(&out, "fastedge-scheme"), Some("http")); + assert_eq!( + header(&out, "fastedge_header_hostname"), + Some("example.com") + ); + } + + #[test] + fn self_binding_ignores_app_host_header() { + let mut state = make_state(Some("example.com")); + let out = state + .backend_request(parts( + "https://example.com:443/path", + &[("host", "app-set-host.com")], + )) + .unwrap(); + assert_eq!(header(&out, "fastedge-hostname"), Some("127.0.0.1")); + assert_eq!(header(&out, "fastedge-scheme"), Some("https")); + // real host comes from the URL, not the app-provided Host header + assert_eq!( + header(&out, "fastedge_header_hostname"), + Some("example.com") + ); + } + + #[test] + fn non_default_port_skips_self_binding() { + let mut state = make_state(Some("example.com")); + let out = state + .backend_request(parts("http://example.com:8080/path", &[])) + .unwrap(); + assert_eq!(header(&out, "fastedge-hostname"), Some("example.com:8080")); + assert_eq!(header(&out, "fastedge_header_hostname"), None); + } + + #[test] + fn mismatch_skips_self_binding() { + let mut state = make_state(Some("other.com")); + let out = state + .backend_request(parts("http://example.com/path", &[])) + .unwrap(); + assert_eq!(header(&out, "fastedge-hostname"), Some("example.com")); + assert_eq!(header(&out, "fastedge_header_hostname"), None); + } + + #[test] + fn no_cdn_real_host_keeps_default_behavior() { + let mut state = make_state(None); + let out = state + .backend_request(parts("http://example.com/path", &[("host", "app.com")])) + .unwrap(); + assert_eq!(header(&out, "fastedge-hostname"), Some("example.com")); + // app Host header is surfaced via Fastedge_Header_Hostname in the default path + assert_eq!(header(&out, "fastedge_header_hostname"), Some("app.com")); + } + + #[test] + fn app_provided_fastedge_header_hostname_is_stripped() { + // Without self-binding and without a Host header, FastEdge does not set + // its own `fastedge_header_hostname`. Any value the app supplied must + // still be filtered out so it cannot spoof the backend's real-host view. + let mut state = make_state(None); + let out = state + .backend_request(parts( + "http://example.com/path", + &[("fastedge_header_hostname", "evil.example.com")], + )) + .unwrap(); + assert_eq!(header(&out, "fastedge_header_hostname"), None); + } + + #[test] + fn app_cannot_override_fastedge_header_hostname_in_self_binding() { + let mut state = make_state(Some("example.com")); + let out = state + .backend_request(parts( + "http://example.com/path", + &[("fastedge_header_hostname", "evil.example.com")], + )) + .unwrap(); + // FastEdge's own value wins; the app-provided one is filtered first. + assert_eq!( + header(&out, "fastedge_header_hostname"), + Some("example.com") + ); + } +}