Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 251 additions & 13 deletions crates/http-backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ type HeaderNameList = Vec<HeaderName>;

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,
Expand Down Expand Up @@ -69,6 +87,7 @@ pub struct Backend<C> {
/// deposits its elapsed wall-clock time (ms) here so the epoch
/// deadline callback can refund those ticks to the guest.
epoch_pause_ms: Arc<AtomicU64>,
cdn_real_host: Option<SmolStr>,
}

pub struct Builder {
Expand Down Expand Up @@ -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,
}
}
}
Expand All @@ -141,6 +161,14 @@ impl<C> Backend<C> {
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<SmolStr> {
self.cdn_real_host.clone()
}

/// Set external request stats
pub fn set_ext_http_stats(&mut self, stats: Arc<dyn ExtRequestStats>) {
self.ext_http_stats.replace(stats);
Expand Down Expand Up @@ -251,6 +279,21 @@ impl<C> Backend<C> {
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)| {
Comment thread
ruslanti marked this conversation as resolved.
let port_ok =
matches!(original_url.port_u16(), None | Some(80) | Some(443));
port_ok && cdn_real_host.eq_ignore_ascii_case(url_host)
});
Comment on lines +291 to +295
Comment on lines +287 to +295

// filter headers
let mut headers = req
.headers
Expand All @@ -259,11 +302,12 @@ impl<C> Backend<C> {
.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, _)| {
Expand All @@ -274,14 +318,31 @@ impl<C> Backend<C> {
})
.collect::<Vec<(String, String)>>();

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));
}
Comment thread
ruslanti marked this conversation as resolved.
}
headers.extend(self.propagate_headers_vec());

Expand Down Expand Up @@ -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::<mock_http_connector::Connector>::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::<mock_http_connector::Connector>::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::<mock_http_connector::Connector>::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::<mock_http_connector::Connector>::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::<mock_http_connector::Connector>::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() {
Expand Down Expand Up @@ -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,
};
Expand Down
8 changes: 8 additions & 0 deletions crates/http-service/src/executor/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions crates/http-service/src/executor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions crates/http-service/src/executor/wasi_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand Down
Loading
Loading