Skip to content
Open
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
120 changes: 117 additions & 3 deletions crates/http-service/src/executor/http.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -187,6 +194,33 @@ fn to_fastedge_http_method(method: &Method) -> anyhow::Result<fastedge::http::Me
})
}

/// Promote a relative request URI to an absolute URI by grafting on the
/// authority advertised in the `x-cdn-real-host` header.
///
/// The URI is left untouched when:
/// * it already has a scheme (i.e. is already absolute), or
/// * `x-cdn-real-host` is missing or not valid UTF-8.
///
/// When rewritten, the scheme is forced to `http` and the authority comes from
/// the header — unless the URI already carries an authority, in which case the
/// existing one is preserved.
fn normalize_request_uri(uri: &mut Uri, headers: &::http::HeaderMap) -> 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::*;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
}
}
106 changes: 86 additions & 20 deletions crates/http-service/src/executor/wasi_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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");
}
}
Loading