This commit is contained in:
Mateusz Gruszczyński committed 2026-10-13 22:54:52 +02:00
1 parent b0f211555a
commit 8b9ca4db44
39 files changed
+1481 -303

No files matched your search

+18 -12
View File
@@ -60,7 +60,23 @@ if pending_revalidation {
if request.method() == Method::HEAD && cache_ticket.is_some() {
*request.method_mut() = Method::GET;
}
*request.uri_mut() = uri.clone();
// Preserve a pre-normalization Host snapshot for pool retries. Each next
// backend gets its OWN target and Host/:authority, never the previous peer's.
let retry_headers = request.headers().clone();
let expected_backend_host = if backend.preserve_host {
&original_host
} else {
&backend.host_header
};
let retry_host_follows_backend = retry_headers.get(HOST)
.is_some_and(|value| value == expected_backend_host);
let Some(upstream_target) = upstream_request_target(
&uri, request.headers_mut(), &backend,
websocket_legacy_support && !websocket_upstream_h2, websocket_upstream_h2,
) else {
return error(StatusCode::BAD_GATEWAY, "RustProxy: invalid upstream authority\n");
};
*request.uri_mut() = upstream_target.clone();
let upstream_version = if websocket_legacy_support && !websocket_upstream_h2 {
Version::HTTP_11
} else {
@@ -107,17 +123,7 @@ let proactive_base_headers = request.headers().clone();
let response_header_timeout = frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.unwrap_or(state.header_timeout);
let retry_headers = request.headers().clone();
let retry_method = request.method().clone();
let expected_backend_host = if backend.preserve_host {
&original_host
} else {
&backend.host_header
};
let retry_host_follows_backend = request
.headers()
.get(HOST)
.is_some_and(|value| value == expected_backend_host);
let retry_policy = route.pool.as_ref().map(|pool| pool.settings.retry.clone());
let retry_enabled = route.backend_id.is_none()
&& !admin_control_plane
@@ -131,7 +137,7 @@ let mut active_backend = backend.clone();
let mut active_lease = backend_lease.clone();
drop(backend_lease);
let mut active_affinity_cookie = affinity_cookie.clone();
let mut active_uri = uri.clone();
let mut active_uri = upstream_target;
let mut request_attempt = Some(request);
let mut attempted_backends = Vec::<String>::new();
let mut retries = 0u8;
+62
View File
@@ -32,6 +32,68 @@ mod authority_tests {
Version::HTTP_2
);
}
#[test]
fn preserve_host_sets_http2_authority_without_changing_network_origin() {
use crate::config::UpstreamProtocol;
for (protocol, scheme, expected_authority, remove_host) in [
(UpstreamProtocol::Http1, "https", "backend.internal:443", false),
(UpstreamProtocol::Auto, "https", "unitraklub.pl", false),
(UpstreamProtocol::Http2, "https", "unitraklub.pl", false),
(UpstreamProtocol::H2, "https", "unitraklub.pl", true),
(UpstreamProtocol::Auto, "http", "10.87.20.2:443", false),
(UpstreamProtocol::Http2, "http", "unitraklub.pl", true),
(UpstreamProtocol::H2, "http", "unitraklub.pl", true),
] {
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
let member = &mut config.vhosts[0].backends[0];
member.url = format!("{scheme}://10.87.20.2:443");
member.allow_private = true;
member.protocol = protocol;
member.preserve_host = true;
if scheme == "https" {
member.tls_server_name = Some("backend.internal".into());
}
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let backend = &snapshot.pools[0].members[0].backend;
let uri = upstream_uri(backend, "/block_refresh/block/10").unwrap();
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_static("unitraklub.pl"));
let target = upstream_request_target(&uri, &mut headers, backend, false, false).unwrap();
assert_eq!(target.authority().unwrap().as_str(), expected_authority, "{protocol:?}");
assert_eq!(target.path(), "/block_refresh/block/10");
assert_eq!(headers.contains_key(HOST), !remove_host);
assert_eq!(backend.origin, format!("{scheme}://10.87.20.2:443"));
}
}
#[test]
fn invalid_outgoing_host_is_rejected_instead_of_used_for_dialing() {
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2;
config.vhosts[0].backends[0].preserve_host = true;
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let backend = &snapshot.pools[0].members[0].backend;
let uri = upstream_uri(backend, "/").unwrap();
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_static("evil@example.invalid"));
assert!(upstream_request_target(&uri, &mut headers, backend, false, false).is_none());
}
#[test]
fn retry_prepares_authority_for_the_selected_backend_not_the_previous_one() {
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2;
config.vhosts[0].backends[0].preserve_host = true;
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let backend = &snapshot.pools[0].members[0].backend;
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_static("public.example"));
let req = retry_request(&Method::GET, Version::HTTP_2, &headers, backend,
&HeaderValue::from_static("public.example"), "/retry", true).unwrap();
assert_eq!(req.uri().authority().unwrap().as_str(), "public.example");
assert!(req.headers().get(HOST).is_none());
}
#[test]
fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required() {
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
+44 -1
View File
@@ -158,6 +158,46 @@ fn legacy_websocket_origin(
HeaderValue::from_str(&format!("{scheme}://{authority}")).ok()
}
/// Align HTTP request authority with the selected public/backend Host.
///
/// TCP/DNS/TLS still target the configured origin in TimedConnector. Strict
/// HTTP/2 sends :authority without a second Host field. HTTPS ALPN mixed
/// mode must retain the Host field: Hyper's legacy H1-compatible request may
/// be sent over an H2-negotiated connection, and H1 fallback needs Host.
fn upstream_request_target(
uri: &Uri,
headers: &mut HeaderMap,
backend: &crate::config::CompiledBackend,
websocket_h1: bool,
websocket_h2: bool,
) -> Option<Uri> {
let h2_capable = !websocket_h1 && match backend.protocol {
crate::config::UpstreamProtocol::Http1 => false,
crate::config::UpstreamProtocol::Auto => backend.origin.starts_with("https://"),
crate::config::UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2 => true,
};
if !h2_capable {
return Some(uri.clone());
}
let selected = headers.get(HOST).unwrap_or(&backend.host_header);
let authority: http::uri::Authority = selected.to_str().ok()?.parse().ok()?;
// Never treat a userinfo URI or a malformed Host as a valid HTTP authority.
if authority.host().is_empty() || authority.as_str().contains('@') {
return None;
}
let mut parts = uri.clone().into_parts();
parts.authority = Some(authority);
let target = Uri::from_parts(parts).ok()?;
let strict_h2_request = websocket_h2
|| backend.protocol == crate::config::UpstreamProtocol::H2
|| (backend.protocol == crate::config::UpstreamProtocol::Http2
&& backend.origin.starts_with("http://"));
if strict_h2_request {
headers.remove(HOST);
}
Some(target)
}
fn upstream_request_version(
backend: &crate::config::CompiledBackend,
websocket_h2: bool,
@@ -188,7 +228,8 @@ fn retry_request(
let mut request = Request::builder()
.method(method.clone())
.version(version)
.uri(uri)
// Keep the original URI for authority adjustment after building the request.
.uri(uri.clone())
.body(Body::empty())
.ok()?;
*request.headers_mut() = headers.clone();
@@ -202,6 +243,8 @@ fn retry_request(
},
);
}
let target = upstream_request_target(&uri, request.headers_mut(), backend, false, false)?;
*request.uri_mut() = target;
Some(request)
}