diff --git a/crates/http-service/src/executor/http.rs b/crates/http-service/src/executor/http.rs index 1d00a46..ee0d957 100644 --- a/crates/http-service/src/executor/http.rs +++ b/crates/http-service/src/executor/http.rs @@ -1,9 +1,9 @@ use crate::executor; -use crate::executor::HttpExecutor; +use crate::executor::{HttpExecutor, X_CDN_REAL_HOST}; use crate::state::HttpState; use anyhow::{Context, anyhow, bail}; use async_trait::async_trait; -use http::{Method, Request, Response, StatusCode}; +use http::{Method, Request, Response, StatusCode, Uri}; use http_backend::Backend; use http_body_util::{BodyExt, Full}; use hyper::body::Body; @@ -40,9 +40,16 @@ where // Start timing for stats let stats_timer = StatsTimer::new(stats.clone()); - let (parts, body) = req.into_parts(); + let (mut parts, body) = req.into_parts(); let method = to_fastedge_http_method(&parts.method)?; + // Promote a relative request URI (origin-form from hyper, e.g. `/foo`) + // to an absolute URI when the `x-cdn-real-host` header is present, so + // guests observe a full `scheme://authority/path` in `request.uri`. + // Mirrors the wasi-http executor, but only fires when we have an + // authority to graft on — otherwise the URI is left as-is. + normalize_request_uri(&mut parts.uri, &parts.headers)?; + let headers = parts .headers .iter() @@ -187,6 +194,33 @@ fn to_fastedge_http_method(method: &Method) -> anyhow::Result anyhow::Result<()> { + if uri.scheme().is_some() { + return Ok(()); + } + let Some(hostname) = headers.get(X_CDN_REAL_HOST).and_then(|v| v.to_str().ok()) else { + return Ok(()); + }; + + let mut uparts = uri.clone().into_parts(); + uparts.scheme = Some(::http::uri::Scheme::HTTP); + if uparts.authority.is_none() { + uparts.authority = hostname.parse().ok(); + } + *uri = Uri::from_parts(uparts)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -198,6 +232,7 @@ mod tests { }; use bytes::Bytes; use claims::*; + use http::HeaderMap; use http_backend::stats::ExtRequestStats; use http_backend::{Backend, BackendStrategy, FastEdgeConnector, SERVER_NAME_HEADER}; use http_body_util::Empty; @@ -895,4 +930,83 @@ mod tests { assert_eq!(StatusCode::NOT_FOUND, res.status()); assert_eq!(0, res.headers().len()); } + + // ── normalize_request_uri ──────────────────────────────────────────── + + fn headers_with_real_host(host: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(X_CDN_REAL_HOST, host.parse().unwrap()); + h + } + + /// Origin-form URI + `x-cdn-real-host` → absolute URI with `http` scheme + /// and the header value as authority. + #[test] + fn normalize_relative_uri_with_real_host_header() { + let mut uri: Uri = assert_ok!("/foo?bar=1".parse()); + let headers = headers_with_real_host("app.example.com"); + + assert_ok!(normalize_request_uri(&mut uri, &headers)); + + assert_eq!(uri.scheme_str(), Some("http")); + assert_eq!(uri.host(), Some("app.example.com")); + assert_eq!(uri.path(), "/foo"); + assert_eq!(uri.query(), Some("bar=1")); + } + + /// No `x-cdn-real-host` → URI is left untouched (relative). + #[test] + fn normalize_relative_uri_without_real_host_header_is_noop() { + let mut uri: Uri = assert_ok!("/foo".parse()); + let original = uri.clone(); + let headers = HeaderMap::new(); + + assert_ok!(normalize_request_uri(&mut uri, &headers)); + + assert_eq!(uri, original); + assert_eq!(uri.scheme(), None); + } + + /// Already-absolute URI is never rewritten, even when the header is set. + #[test] + fn normalize_absolute_uri_is_noop() { + let mut uri: Uri = assert_ok!("https://upstream.example.com/path".parse()); + let original = uri.clone(); + let headers = headers_with_real_host("override.example.com"); + + assert_ok!(normalize_request_uri(&mut uri, &headers)); + + assert_eq!(uri, original); + assert_eq!(uri.scheme_str(), Some("https")); + assert_eq!(uri.host(), Some("upstream.example.com")); + } + + /// Non-UTF-8 header value is silently ignored: behaves like a missing header. + #[test] + fn normalize_with_non_utf8_real_host_header_is_noop() { + let mut uri: Uri = assert_ok!("/foo".parse()); + let original = uri.clone(); + let mut headers = HeaderMap::new(); + // Bytes that are valid HeaderValue but not valid UTF-8. + headers.insert( + X_CDN_REAL_HOST, + ::http::HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(), + ); + + assert_ok!(normalize_request_uri(&mut uri, &headers)); + + assert_eq!(uri, original); + } + + /// Invalid authority in the header → URI cannot be rebuilt; helper errors. + #[test] + fn normalize_with_invalid_authority_errors() { + let mut uri: Uri = assert_ok!("/foo".parse()); + // Spaces are not legal in an authority; this triggers the + // `Uri::from_parts` path with `authority = None` and an HTTP scheme, + // which is itself invalid. + let headers = headers_with_real_host("not a valid host"); + + assert_err!(normalize_request_uri(&mut uri, &headers)); + } } diff --git a/crates/http-service/src/executor/wasi_http.rs b/crates/http-service/src/executor/wasi_http.rs index 05b5490..04926c5 100644 --- a/crates/http-service/src/executor/wasi_http.rs +++ b/crates/http-service/src/executor/wasi_http.rs @@ -3,12 +3,12 @@ use std::sync::atomic::AtomicU64; use std::time::Duration; use crate::executor; -use crate::executor::HttpExecutor; +use crate::executor::{HttpExecutor, X_CDN_REAL_HOST}; use crate::state::HttpState; use ::http::{HeaderMap, Request, Response, Uri, header}; use anyhow::{Context, anyhow, bail}; use async_trait::async_trait; -use http_backend::{Backend, SERVER_NAME_HEADER}; +use http_backend::Backend; use http_body_util::{BodyExt, Full}; use hyper::body::Body; use runtime::util::stats::{StatsTimer, StatsVisitor}; @@ -52,24 +52,13 @@ where .hostname() .context("backend hostname must be set")?; let backend_host_header = backend_hostname.parse().context("invalid hostname")?; - // Resolve backend hostname using the following precedence: - // 1. `server_name` request header (if set and valid UTF-8) - // 2. backend's configured hostname - // 3. fallback to "localhost" - let hostname: SmolStr = parts - .headers - .get(SERVER_NAME_HEADER) - .and_then(|v| v.to_str().ok()) - .map(SmolStr::from) - .unwrap_or_else(|| match backend_hostname.find('.') { - None => backend_hostname, - Some(i) => { - let (_, domain) = backend_hostname.split_at(i + 1); - SmolStr::from(domain) - } - }); + let hostname = resolve_authority(&parts.headers, backend_hostname); - // fix relative uri to absolute + // Promote a relative request URI (origin-form from hyper, e.g. `/foo`) + // to an absolute URI so the wasi-http `incoming-request` resource + // exposes a valid scheme + authority to the guest. `Uri::from_parts` + // also requires authority once a scheme is set, so both fields must be + // populated together. if parts.uri.scheme().is_none() { let mut uparts = parts.uri.clone().into_parts(); uparts.scheme = Some(::http::uri::Scheme::HTTP); @@ -102,7 +91,7 @@ where if let Some(cdn_real_host) = parts .headers - .get(executor::X_CDN_REAL_HOST) + .get(X_CDN_REAL_HOST) .and_then(|v| v.to_str().ok()) { http_backend.set_cdn_real_host(cdn_real_host.into()); @@ -215,3 +204,80 @@ where } } } + +/// Resolve the authority used to absolutize the incoming request URI. +/// +/// Precedence: +/// 1. `x-cdn-real-host` request header (if set and valid UTF-8). +/// 2. The backend's configured hostname stripped to its parent domain +/// (e.g. `app.example.com` -> `example.com`). +/// 3. The backend's configured hostname as-is when it contains no dot. +fn resolve_authority(headers: &HeaderMap, backend_hostname: SmolStr) -> SmolStr { + headers + .get(X_CDN_REAL_HOST) + .and_then(|v| v.to_str().ok()) + .map(SmolStr::from) + .unwrap_or_else(|| match backend_hostname.find('.') { + None => backend_hostname, + Some(i) => { + let (_, domain) = backend_hostname.split_at(i + 1); + SmolStr::from(domain) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ::http::HeaderValue; + + fn headers_with_real_host(host: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(X_CDN_REAL_HOST, host.parse().unwrap()); + h + } + + /// `x-cdn-real-host` wins over the backend hostname. + #[test] + fn resolve_authority_prefers_real_host_header() { + let headers = headers_with_real_host("override.test.com"); + let authority = resolve_authority(&headers, SmolStr::from("backend.example.com")); + assert_eq!(authority, "override.test.com"); + } + + /// Without the header, the backend hostname is stripped to its parent domain. + #[test] + fn resolve_authority_falls_back_to_parent_domain() { + let headers = HeaderMap::new(); + let authority = resolve_authority(&headers, SmolStr::from("app.example.com")); + assert_eq!(authority, "example.com"); + } + + /// Multi-level subdomains: only the leftmost label is removed. + #[test] + fn resolve_authority_strips_only_leftmost_label() { + let headers = HeaderMap::new(); + let authority = resolve_authority(&headers, SmolStr::from("a.b.c.example.com")); + assert_eq!(authority, "b.c.example.com"); + } + + /// Single-label hostnames have no dot, so they're returned unchanged. + #[test] + fn resolve_authority_keeps_single_label_host() { + let headers = HeaderMap::new(); + let authority = resolve_authority(&headers, SmolStr::from("localhost")); + assert_eq!(authority, "localhost"); + } + + /// Non-UTF-8 header value is silently ignored: behaves like a missing header. + #[test] + fn resolve_authority_ignores_non_utf8_header() { + let mut headers = HeaderMap::new(); + headers.insert( + X_CDN_REAL_HOST, + HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(), + ); + let authority = resolve_authority(&headers, SmolStr::from("backend.example.com")); + assert_eq!(authority, "example.com"); + } +}