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

+3 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.32.3",
"version": "0.32.4",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -1981,7 +1981,8 @@
},
"preserve_host": {
"type": "boolean",
"default": true
"default": true,
"description": "Keep the original client Host for HTTP/1.1 or HTTP/2 :authority, independently of origin URL, TCP destination and TLS SNI. Supported with http1, auto, http2 and h2."
},
"protocol": {
"type": "string",
+695 -183
View File
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -207,7 +207,8 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
// Preserve Host is a request-authority choice, not an HTTP/1.1-only option.
// The Rust forwarder keeps HTTP/2 :authority consistent with this value.
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
@@ -258,7 +259,7 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
controls["member-pool"].checked = true; controls["member-private"].checked = true;
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; protocol.dispatchEvent(new Event("change")); syncRisk(); changed();
}));
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
@@ -296,7 +297,10 @@ function debugOriginIssues(members) {
let parsed; try { parsed = new URL(member.url); } catch { return false; }
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
if (parsed.protocol !== "https:" || !ipHost) return false;
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
// The troubleshooting preset is OPTIONAL, not a protocol compatibility rule.
// Warn only when IP-host TLS verification may need an explicit identity;
// HTTP/1.1, Auto and H2 plus preserve_host are all valid configurations.
return member.tls_verify === true && !String(member.tls_server_name || "").trim();
});
}
function aclEditor(parent, initial, onChange = () => { }) {
-2
View File
@@ -12,8 +12,6 @@ pub(crate) fn compile_backend_with_timeout(
matches!(url.scheme(), "http" | "https"),
"backend scheme must be http or https"
);
ensure!(backend.protocol == UpstreamProtocol::Http1 || !backend.preserve_host,
"HTTP/2 or auto upstream requires preserve_host=false; use http1 for original Host preservation");
ensure!(
backend.tls_ca_pem.is_none() || url.scheme() == "https",
"backend additional CA can only be used with an https:// URL; remove the CA or change the backend URL to HTTPS"
+19
View File
@@ -348,6 +348,25 @@ fn force_ssl_requires_plain_http_and_chooses_https_target() {
let error = compile(c, 4, &policy()).unwrap_err().to_string();
assert!(error.contains("plain HTTP listener"), "{error}");
}
#[test]
fn preserve_host_is_valid_with_every_upstream_protocol() {
for protocol in [
UpstreamProtocol::Http1,
UpstreamProtocol::Auto,
UpstreamProtocol::Http2,
UpstreamProtocol::H2,
] {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let member = &mut config.vhosts[0].backends[0];
member.url = "https://10.87.20.2:443".into();
member.allow_private = true;
member.preserve_host = true;
member.protocol = protocol;
let snapshot = compile(config, 1, &policy()).unwrap();
assert!(snapshot.pools[0].members[0].backend.preserve_host);
}
}
#[test]
fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
+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)
}
+7 -1
View File
@@ -196,6 +196,9 @@ pub struct TimedConnector {
inner: InnerConnector,
policy: AddressPolicy,
timeout: Duration,
/// The configured backend/TLS identity, never the forwarded HTTP Host.
/// The resolver separately pins DNS to the configured backend URL.
destination: Uri,
}
impl Service<Uri> for TimedConnector {
type Response = <InnerConnector as Service<Uri>>::Response;
@@ -205,7 +208,10 @@ impl Service<Uri> for TimedConnector {
fn poll_ready(&mut self, cx: &mut TaskContext<'_>) -> Poll<std::result::Result<(), BoxError>> {
self.inner.poll_ready(cx).map_err(Into::into)
}
fn call(&mut self, uri: Uri) -> Self::Future {
fn call(&mut self, _request_uri: Uri) -> Self::Future {
// A preserved HTTP/2 :authority can be a public vhost name. Never
// resolve or dial it: all sockets must target the configured origin.
let uri = self.destination.clone();
if let Some(ip) = uri.host().and_then(|host| {
host.trim_start_matches('[')
.trim_end_matches(']')
+17
View File
@@ -364,10 +364,27 @@ fn build_raw_client(factory: &ClientFactory) -> Result<RawProxyClient> {
)
.http2_keep_alive_timeout(Duration::from_secs(10))
.http2_keep_alive_while_idle(false);
// The request URI is free to carry the preserved public H2 :authority,
// while TLS SNI/verification uses tls_server_name (or backend URL) and
// DNS/TCP remains restricted to backend.url by GuardedResolver.
let origin = url::Url::parse(&backend.url)?;
let network_host = match origin.host().context("backend host missing")? {
url::Host::Ipv6(ip) => format!("[{ip}]"),
host => host.to_string(),
};
let port = origin.port_or_known_default().context("backend port missing")?;
let tls_name = backend.tls_server_name
.as_deref()
.map(crate::config::normalize_host)
.transpose()?
.unwrap_or(network_host);
let destination: http::Uri = format!("{}://{tls_name}:{port}", origin.scheme())
.parse().context("invalid configured backend transport authority")?;
Ok(builder.build(TimedConnector {
inner,
policy: policy.clone(),
timeout,
destination,
}))
}
+151
View File
@@ -221,3 +221,154 @@ fn default_protocol_and_serialization_are_backward_compatible() {
);
}
}
// Exercise real TLS ALPN and the actual Hyper connector rather than only the
// local authority helper. The public Host must never become a DNS/TCP target.
async fn tls_authority_origin(
offer_h2: bool,
) -> (
std::net::SocketAddr,
tokio::sync::oneshot::Receiver<(Version, Option<String>, Option<String>, Option<String>)>,
tokio::task::JoinHandle<()>,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let pair = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let key = rustls::pki_types::PrivatePkcs8KeyDer::from(pair.signing_key.serialize_der());
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions().unwrap()
.with_no_client_auth()
.with_single_cert(vec![pair.cert.der().clone()], key.into()).unwrap();
config.alpn_protocols = if offer_h2 {
vec![b"h2".to_vec(), b"http/1.1".to_vec()]
} else {
vec![b"http/1.1".to_vec()]
};
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
let (observed_tx, observed_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let tls = acceptor.accept(stream).await.unwrap();
let sni = tls.get_ref().1.server_name().map(str::to_owned);
let observed = Arc::new(std::sync::Mutex::new(Some(observed_tx)));
let service = service_fn(move |request: Request<hyper::body::Incoming>| {
let observed = observed.clone();
let sni = sni.clone();
async move {
if let Some(tx) = observed.lock().unwrap().take() {
let authority = request.uri().authority().map(|v| v.as_str().to_owned());
let host = request.headers().get("host")
.map(|v| v.to_str().unwrap().to_owned());
let _ = tx.send((request.version(), authority, host, sni));
}
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok"))))
}
});
if offer_h2 {
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(tls), service).await;
} else {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(tls), service).await;
}
});
(address, observed_rx, server)
}
#[tokio::test]
async fn strict_h2_preserves_public_authority_while_tcp_and_sni_use_private_origin() {
let (address, seen, server) = tls_authority_origin(true).await;
let mut backend = protocol_backend(address, "https", "h2");
backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_2)
.uri("https://unitraklub.pl/block_refresh/block/10")
.body(Body::empty()).unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap();
assert_eq!(response.version(), Version::HTTP_2);
let (version, authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_2);
assert_eq!(authority.as_deref(), Some("unitraklub.pl"));
assert!(host.is_none(), "Strict H2 must not duplicate :authority with Host");
assert_eq!(sni.as_deref(), Some("localhost"));
server.abort();
}
#[tokio::test]
async fn auto_h1_fallback_preserves_public_host_without_resolving_it() {
let (address, seen, server) = tls_authority_origin(false).await;
let mut backend = protocol_backend(address, "https", "auto");
backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_11)
.uri("https://unitraklub.pl/block_refresh/block/10")
.header("host", "unitraklub.pl")
.body(Body::empty()).unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap();
assert_eq!(response.version(), Version::HTTP_11);
let (version, _authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_11);
assert_eq!(host.as_deref(), Some("unitraklub.pl"));
assert_eq!(sni.as_deref(), Some("localhost"));
server.abort();
}
#[tokio::test]
async fn auto_h2_alpn_retains_same_public_host_and_pinned_tls_origin() {
let (address, seen, server) = tls_authority_origin(true).await;
let mut backend = protocol_backend(address, "https", "auto");
backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_11)
.uri("https://unitraklub.pl/block_refresh/block/10")
.header("host", "unitraklub.pl")
.body(Body::empty()).unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap();
assert_eq!(response.version(), Version::HTTP_2);
let (version, authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_2);
// Legacy Hyper may preserve H1-compatible Host on ALPN H2; neither value
// may select a different origin or differ from the intended public name.
assert_eq!(authority.or(host).as_deref(), Some("unitraklub.pl"));
assert_eq!(sni.as_deref(), Some("localhost"));
server.abort();
}
#[tokio::test]
async fn cleartext_h2c_preserves_public_authority_and_pins_socket() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (tx, observed) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let result = Arc::new(std::sync::Mutex::new(Some(tx)));
let service = service_fn(move |request: Request<hyper::body::Incoming>| {
let result = result.clone();
async move {
if let Some(tx) = result.lock().unwrap().take() {
let _ = tx.send(request.uri().authority().map(|v| v.as_str().to_owned()));
}
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok"))))
}
});
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(stream), service).await;
});
let backend = protocol_backend(address, "http", "h2");
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_2)
.uri("http://unitraklub.pl/test")
.body(Body::empty()).unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap();
assert_eq!(response.version(), Version::HTTP_2);
assert_eq!(observed.await.unwrap().as_deref(), Some("unitraklub.pl"));
server.abort();
}