Fix Host header for HTTP/2 upstream requests (Gitea self-check mismatch)

HTTP/2 requests carry the client's host only in the :authority
pseudo-header, with no literal Host header. build_upstream_request
copied headers verbatim, so the upstream request had no Host, and
hyper-util's legacy client (set_host=true default) filled it in from
the upstream URI, sending Host: 127.0.0.1:3000 instead of the client's
host. Backends like Gitea build absolute URLs from that host, causing
the admin self-check mismatch and intermittent wrong asset URLs.

Reconstruct Host from the request URI authority for HTTP/2, preserve
the client's Host header for HTTP/1.1, and let the URI authority win
if both are present.
This commit is contained in:
2026-09-12 05:38:52 +00:00
parent 71ad3c2905
commit 203fcadffd
3 changed files with 243 additions and 3 deletions
+3 -2
View File
@@ -139,7 +139,7 @@ existing `X-Forwarded-For` headers from the client cannot be trusted.
| Header | Value Source | Notes |
|--------|-------------|-------|
| `Host` | Original request `Host` header | Preserved as-is |
| `Host` | Client's host: the literal `Host` header for HTTP/1.1, or the `:authority` pseudo-header (request URI authority) for HTTP/2 | Reconstructed for HTTP/2, which carries no literal `Host` header (RFC 9113 §8.3.1). Sent as `127.0.0.1:3000` otherwise, breaking backends that build absolute URLs from it (e.g. Gitea). If both URI authority and `Host` header are present, the authority wins. |
| `X-Real-IP` | `ConnectInfo<SocketAddr>` remote IP | Set to client's IP address |
| `X-Forwarded-For` | `ConnectInfo<SocketAddr>` remote IP | **Replaced**, not appended. The proxy is the edge proxy — there are no trusted proxies upstream, so existing `X-Forwarded-For` values from the client cannot be trusted. |
| `X-Forwarded-Proto` | Determined by which listener port received the request | `https` for requests on the listener's `https_port`, `http` for requests on the listener's `http_port`. Note: since the TLS-terminating listener only receives HTTPS connections, this is always `"https"` in practice. The HTTP redirect listener sends a 301 redirect rather than proxying, so `X-Forwarded-Proto` is not set there. See OQ-11. |
@@ -159,7 +159,8 @@ The proxy handler constructs a new request to the upstream:
return 502 Bad Gateway and log the error at `warn` level. The proxy must
never silently drop parts of the URI (such as the query string) — a
malformed upstream URI is an error, not a recoverable condition.
2. Copy the request method, headers, and body from the original
2. Copy the request method, headers, and body from the original, normalizing
the `Host` header to the client's host (see the table above)
3. Inject proxy headers (X-Real-IP, X-Forwarded-For, X-Forwarded-Proto)
4. Remove hop-by-hop headers (Connection, Keep-Alive, Transfer-Encoding, etc.)
5. Send the request via a shared hyper Client instance
+115 -1
View File
@@ -6,7 +6,7 @@ use std::time::Instant;
use arc_swap::ArcSwap;
use axum::body::Body;
use axum::extract::{ConnectInfo, State};
use axum::http::{Request, StatusCode, Uri};
use axum::http::{HeaderName, HeaderValue, Request, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::Router;
use hyper_util::client::legacy::connect::HttpConnector;
@@ -216,14 +216,42 @@ fn build_upstream_uri(scheme: &str, upstream: &str, original_uri: &Uri) -> Resul
}
fn build_upstream_request(req: Request<Body>, upstream_uri: &Uri) -> anyhow::Result<Request<Body>> {
let mut req = req;
let mut builder = Request::builder()
.method(req.method().clone())
.uri(upstream_uri.clone());
// The forwarded Host header must be the host the client used (e.g.
// "git.alk.dev"), not the upstream authority (e.g. "127.0.0.1:3000"),
// otherwise backends like Gitea generate wrong absolute URLs.
//
// RFC 9113 §8.3.1: in HTTP/2 the client's host lives in the `:authority`
// pseudo-header, which hyper surfaces as the request URI's authority; no
// literal `host` header exists. For HTTP/1.1 the request URI is
// origin-form (no authority) and the client's `host` header is the
// authority. When both are present, the URI authority wins so a stale or
// spoofed `host` header cannot override the routed host.
let host_header: Option<HeaderValue> = if let Some(authority) = req.uri().authority() {
let value = match authority.port_u16() {
Some(port) => format!("{}:{}", authority.host(), port),
None => authority.host().to_string(),
};
Some(HeaderValue::from_str(&value).map_err(|e| {
anyhow::anyhow!("client authority {:?} is not a valid Host header: {}", value, e)
})?)
} else {
req.headers().get(axum::http::header::HOST).cloned()
};
req.headers_mut().remove(HeaderName::from_static("host"));
for (name, value) in req.headers().iter() {
builder = builder.header(name.as_str(), value);
}
if let Some(host) = host_header {
builder = builder.header(HeaderName::from_static("host"), host);
}
builder.body(req.into_body()).map_err(Into::into)
}
@@ -393,4 +421,90 @@ mod tests {
let result = build_upstream_uri("https", "upstream.example.com", &uri).unwrap();
assert_eq!(result.to_string(), "https://upstream.example.com/secure");
}
fn make_request(host_header: Option<&str>, uri: &str) -> Request<Body> {
let mut builder = Request::builder().method("GET").uri(uri);
if let Some(h) = host_header {
builder = builder.header("host", h);
}
builder.body(Body::empty()).unwrap()
}
#[tokio::test]
async fn build_upstream_request_sets_host_from_upstream_uri() {
let uri: Uri = "http://127.0.0.1:3000/".parse().unwrap();
let req = make_request(None, "http://127.0.0.1:3000/");
let upstream_req = build_upstream_request(req, &uri).unwrap();
assert_eq!(upstream_req.headers().get("host").unwrap(), "127.0.0.1:3000");
}
#[tokio::test]
async fn build_upstream_request_includes_authority_port_in_host() {
let uri: Uri = "http://git.alk.dev:8443/".parse().unwrap();
let req = make_request(None, "http://git.alk.dev:8443/");
let upstream_req = build_upstream_request(req, &uri).unwrap();
assert_eq!(upstream_req.headers().get("host").unwrap(), "git.alk.dev:8443");
}
#[tokio::test]
async fn build_upstream_request_reconstructs_host_from_authority_for_h2() {
let uri: Uri = "http://127.0.0.1:3000/".parse().unwrap();
let req = make_request(None, "http://127.0.0.1:3000/");
let upstream_req = build_upstream_request(req, &uri).unwrap();
assert_eq!(upstream_req.headers().get("host").unwrap(), "127.0.0.1:3000");
}
#[tokio::test]
async fn build_upstream_request_h2_authority_wins_over_stale_host_header() {
let uri: Uri = "http://127.0.0.1:3000/".parse().unwrap();
let req = make_request(Some("evil.example.com"), "http://127.0.0.1:3000/");
let upstream_req = build_upstream_request(req, &uri).unwrap();
assert_eq!(upstream_req.headers().get("host").unwrap(), "127.0.0.1:3000");
}
#[tokio::test]
async fn build_upstream_request_omits_default_port_in_host() {
let uri: Uri = "http://git.example.com/".parse().unwrap();
let req = make_request(None, "http://git.example.com/");
let upstream_req = build_upstream_request(req, &uri).unwrap();
assert_eq!(upstream_req.headers().get("host").unwrap(), "git.example.com");
}
#[tokio::test]
async fn build_upstream_request_brackets_ipv6_authority() {
let uri: Uri = "http://[::1]:3000/".parse().unwrap();
let req = make_request(None, "http://[::1]:3000/");
let upstream_req = build_upstream_request(req, &uri).unwrap();
assert_eq!(upstream_req.headers().get("host").unwrap(), "[::1]:3000");
}
#[tokio::test]
async fn build_upstream_request_keeps_method_headers_and_body() {
let uri: Uri = "http://127.0.0.1:3000/api".parse().unwrap();
let req = Request::builder()
.method("POST")
.uri("http://127.0.0.1:3000/api")
.header("x-custom", "yes")
.body(Body::from("payload"))
.unwrap();
let upstream_req = build_upstream_request(req, &uri).unwrap();
assert_eq!(upstream_req.method(), "POST");
assert_eq!(upstream_req.uri().path(), "/api");
assert_eq!(upstream_req.headers().get("x-custom").unwrap(), "yes");
assert_eq!(
http_body_util::BodyExt::collect(upstream_req.into_body())
.await
.unwrap()
.to_bytes(),
"payload"
);
}
#[tokio::test]
async fn build_upstream_request_h1_client_host_header_is_forwarded() {
let uri: Uri = "/some/path".parse().unwrap();
let req = make_request(Some("git.alk.dev"), "/some/path");
let upstream_req = build_upstream_request(req, &uri).unwrap();
assert_eq!(upstream_req.headers().get("host").unwrap(), "git.alk.dev");
}
}
+125
View File
@@ -14,6 +14,7 @@ use reverse_proxy::config::dynamic_config::{
};
use reverse_proxy::proxy::body_limit::DEFAULT_BODY_LIMIT_BYTES;
use reverse_proxy::proxy::router_with_body_limit;
use tower::ServiceExt;
#[tokio::test]
async fn test_upstream_spawn_and_connect() {
@@ -566,6 +567,130 @@ async fn test_http_redirect_acme_challenge_returns_404() {
handle.abort();
}
async fn spawn_echoing_upstream() -> helpers::http_test_helper::TestUpstream {
helpers::http_test_helper::TestUpstream::spawn(|| {
Router::new().route(
"/",
get(|req: axum::extract::Request| async move {
let host = req
.headers()
.get("host")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
let proto = req
.headers()
.get("x-forwarded-proto")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
format!("host={}|proto={}", host, proto)
}),
)
})
.await
}
fn make_site_config(upstream_addr: &str) -> SiteConfig {
SiteConfig {
host: "test.local".to_string(),
upstream: upstream_addr.to_string(),
upstream_scheme: "http".to_string(),
upstream_connect_timeout_secs: 5,
upstream_request_timeout_secs: 60,
}
}
fn make_dynamic_config_with_site(upstream_addr: &str) -> DynamicConfig {
DynamicConfig::from_sites(
vec![make_site_config(upstream_addr)],
RateLimitConfig {
requests_per_second: 100,
burst: 100,
},
BodyConfig {
limit_bytes: 104857600,
},
)
}
fn make_https_test_proxy_state(upstream_addr: &str) -> Arc<reverse_proxy::proxy::ProxyState> {
Arc::new(reverse_proxy::proxy::ProxyState {
config: Arc::new(ArcSwap::from_pointee(make_dynamic_config_with_site(
upstream_addr,
))),
http_client: reverse_proxy::proxy::create_http_client(),
https_client: reverse_proxy::proxy::create_https_client(),
})
}
// Regression test: Gitea's self-check reported "Current URL doesn't match the
// URL seen by Gitea" because HTTP/2 requests carried the upstream authority
// (127.0.0.1:3000) as the Host header instead of the client's :authority
// (test.local). HTTP/2 requests have no literal Host header, so the proxy
// must reconstruct it from the request URI's authority. Without the fix, the
// hyper-util client (set_host=true) inserts the upstream authority.
#[tokio::test]
async fn test_proxy_forwards_client_authority_as_host() {
let upstream = spawn_echoing_upstream().await;
let upstream_addr = format!("127.0.0.1:{}", upstream.addr.port());
let proxy_state = make_https_test_proxy_state(&upstream_addr);
let config_arc = Arc::new(ArcSwap::from_pointee(make_dynamic_config_with_site(
&upstream_addr,
)));
let rate_limiter =
Arc::new(reverse_proxy::rate_limit::RateLimiter::new(config_arc.clone()));
let router = reverse_proxy::proxy::build_router(proxy_state, config_arc, rate_limiter);
// Simulates an HTTP/2 request translated by hyper: the :authority
// pseudo-header becomes the URI authority and no Host header is present.
let mut req = axum::http::Request::builder()
.method("GET")
.uri("http://test.local/")
.header("x-forwarded-proto", "https")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(axum::extract::ConnectInfo(
std::net::SocketAddr::from(([127, 0, 0, 1], 54321)),
));
let resp = router.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(body, "host=test.local|proto=https");
let _ = upstream.shutdown_tx.send(());
}
// HTTP/1.1 requests carry a real Host header and an origin-form request
// target — the Host header must be routed on and forwarded unchanged.
#[tokio::test]
async fn test_proxy_preserves_client_host_header_for_http1() {
let upstream = spawn_echoing_upstream().await;
let upstream_addr = format!("127.0.0.1:{}", upstream.addr.port());
let proxy_state = make_https_test_proxy_state(&upstream_addr);
let config_arc = Arc::new(ArcSwap::from_pointee(make_dynamic_config_with_site(
&upstream_addr,
)));
let rate_limiter =
Arc::new(reverse_proxy::rate_limit::RateLimiter::new(config_arc.clone()));
let router = reverse_proxy::proxy::build_router(proxy_state, config_arc, rate_limiter);
let mut req = axum::http::Request::builder()
.method("GET")
.uri("/")
.header("host", "test.local")
.header("x-forwarded-proto", "https")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(axum::extract::ConnectInfo(
std::net::SocketAddr::from(([127, 0, 0, 1], 54322)),
));
let resp = router.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(body, "host=test.local|proto=https");
let _ = upstream.shutdown_tx.send(());
}
fn write_valid_config(dir: &Path) -> std::path::PathBuf {
let config_path = dir.join("config.toml");
let config = r#"