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:
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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#"
|
||||
|
||||
Reference in New Issue
Block a user