This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+662
View File
@@ -0,0 +1,662 @@
//! Per-backend connection pools and connect-time DNS validation.
//! No HTTP caller can supply a destination. DNS answers are validated once and
//! passed directly to HttpConnector; there is no second unvalidated lookup.
use crate::config::{ip_policy, Backend, UpstreamProtocol};
use anyhow::{ensure, Context, Result};
use axum::body::Body;
use http::Uri;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use hyper_util::{
client::legacy::{
connect::{dns::Name, HttpConnector},
Client,
},
rt::{TokioExecutor, TokioTimer},
};
use ipnet::IpNet;
use rustls::{
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
pki_types::{CertificateDer, ServerName, UnixTime},
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
};
use std::{
future::Future,
io,
net::{IpAddr, SocketAddr, ToSocketAddrs},
pin::Pin,
sync::{Arc, OnceLock},
task::{Context as TaskContext, Poll},
time::Duration,
};
use tokio::sync::Semaphore;
use tower::Service;
const DNS_TIMEOUT: Duration = Duration::from_secs(5);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_DNS_ANSWERS: usize = 32;
static DNS_SLOTS: OnceLock<Arc<Semaphore>> = OnceLock::new();
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub type ProxyClient = Client<TimedConnector, Body>;
#[derive(Clone, Debug, Default)]
pub struct AddressPolicy {
pub allow_private: bool,
pub accept_local_listener_risk: bool,
pub allowlist: Vec<IpNet>,
port: u16,
listeners: Vec<SocketAddr>,
local_ips: Vec<IpAddr>,
interfaces_known: bool,
}
impl AddressPolicy {
pub fn new(backend: &Backend) -> Result<Self> {
ensure!(
backend.address_allowlist.len() <= 32,
"at most 32 backend CIDR allowlist entries"
);
let allowlist = backend
.address_allowlist
.iter()
.map(|network| {
ensure!(network.len() <= 64, "CIDR is too long");
network
.parse::<IpNet>()
.context("invalid backend CIDR allowlist entry")
})
.collect::<Result<Vec<_>>>()?;
Ok(Self {
allow_private: backend.allow_private,
accept_local_listener_risk: backend.accept_local_listener_risk,
allowlist,
port: url::Url::parse(&backend.url)?
.port_or_known_default()
.unwrap_or(0),
..Default::default()
})
}
pub fn with_listeners(mut self, policy: &crate::config::ValidationPolicy) -> Self {
self.listeners = vec![policy.proxy_addr, policy.admin_addr];
self.listeners.extend(policy.tls_addr);
let addresses = local_addresses();
self.interfaces_known = addresses.is_some();
self.local_ips = addresses.unwrap_or_default();
self
}
pub fn with_bindings(mut self, bindings: &[SocketAddr]) -> Self {
self.listeners.extend_from_slice(bindings);
self.listeners.sort();
self.listeners.dedup();
self
}
pub fn check(&self, ip: IpAddr) -> Result<()> {
let ip = match ip {
IpAddr::V6(ip) => ip
.to_ipv4_mapped()
.map(IpAddr::V4)
.unwrap_or(IpAddr::V6(ip)),
ip => ip,
};
ip_policy(ip, self.allow_private)?;
for listener in &self.listeners {
if listener.port() != self.port {
continue;
}
let local = match listener.ip() {
IpAddr::V6(ip) => ip
.to_ipv4_mapped()
.map(IpAddr::V4)
.unwrap_or(IpAddr::V6(ip)),
ip => ip,
};
let local_interface =
self.local_ips.contains(&ip) || (!self.interfaces_known && locally_bindable(ip));
let self_target = ip == local
|| (ip.is_loopback() && local.is_loopback())
|| (local.is_unspecified() && (ip.is_loopback() || local_interface));
ensure!(
!self_target || self.accept_local_listener_risk,
"backend resolves to a local RustProxy listener ({ip}:{} via {listener}); set allow_private=true and accept_local_listener_risk=true only after accepting proxy-loop/control-plane exposure risk",
self.port
);
}
ensure!(
self.allowlist.is_empty() || self.allowlist.iter().any(|network| network.contains(&ip)),
"backend address is outside its CIDR allowlist"
);
Ok(())
}
fn validate_answers(
&self,
answers: impl Iterator<Item = SocketAddr>,
) -> io::Result<Vec<SocketAddr>> {
let mut approved = Vec::new();
for (index, address) in answers.enumerate() {
if index >= MAX_DNS_ANSWERS {
return Err(io::Error::other("too many DNS answers"));
}
self.check(address.ip())
.map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))?;
if !approved.contains(&address) {
approved.push(address);
}
}
if approved.is_empty() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"DNS returned no usable addresses",
));
}
Ok(approved)
}
}
#[derive(Clone, Debug)]
pub struct GuardedResolver {
policy: AddressPolicy,
/// When TLS uses a separate verified server name, resolve/connect only the
/// hostname from the configured backend URL, never the SNI hostname.
connect_host: Option<String>,
}
impl Service<Name> for GuardedResolver {
type Response = std::vec::IntoIter<SocketAddr>;
type Error = io::Error;
type Future = Pin<Box<dyn Future<Output = io::Result<Self::Response>> + Send>>;
fn poll_ready(&mut self, _: &mut TaskContext<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, name: Name) -> Self::Future {
let policy = self.policy.clone();
let hostname = self
.connect_host
.clone()
.unwrap_or_else(|| name.as_str().to_owned());
let permit = DNS_SLOTS
.get_or_init(|| Arc::new(Semaphore::new(16)))
.clone()
.try_acquire_owned();
Box::pin(async move {
let permit = permit.map_err(|_| {
io::Error::new(io::ErrorKind::WouldBlock, "DNS worker limit reached")
})?;
let lookup = tokio::task::spawn_blocking(move || {
// The permit lives inside the worker even if the caller times out.
// An OS resolver stuck in getaddrinfo cannot create an unbounded worker queue.
let _permit = permit;
policy.validate_answers((hostname.as_str(), 0).to_socket_addrs()?)
});
let result = tokio::time::timeout(DNS_TIMEOUT, lookup)
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "DNS lookup timed out"))?
.map_err(|e| io::Error::other(e.to_string()))??;
Ok(result.into_iter())
})
}
}
type InnerConnector = HttpsConnector<HttpConnector<GuardedResolver>>;
#[derive(Clone, Debug)]
pub struct TimedConnector {
inner: InnerConnector,
policy: AddressPolicy,
timeout: Duration,
}
impl Service<Uri> for TimedConnector {
type Response = <InnerConnector as Service<Uri>>::Response;
type Error = BoxError;
type Future =
Pin<Box<dyn Future<Output = std::result::Result<Self::Response, BoxError>> + Send>>;
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 {
if let Some(ip) = uri.host().and_then(|host| {
host.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
.ok()
}) {
if let Err(error) = self.policy.check(ip) {
return Box::pin(async move {
Err(Box::new(io::Error::new(
io::ErrorKind::PermissionDenied,
error.to_string(),
)) as BoxError)
});
}
}
let future = self.inner.call(uri);
let timeout = self.timeout;
Box::pin(async move {
match tokio::time::timeout(timeout, future).await {
Ok(result) => result.map_err(Into::into),
Err(error) => Err(Box::new(error) as BoxError),
}
})
}
}
fn public_roots() -> RootCertStore {
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
roots
}
fn roots(backend: &Backend) -> Result<RootCertStore> {
let mut roots = public_roots();
if let Some(pem) = &backend.tls_ca_pem {
ensure!(pem.len() <= 65_536, "additional CA PEM exceeds 64 KiB");
let certs = rustls_pemfile::certs(&mut pem.as_bytes()).collect::<io::Result<Vec<_>>>()?;
ensure!(
!certs.is_empty() && certs.len() <= 16,
"additional CA PEM must contain 1..16 certificates"
);
for cert in certs {
roots.add(cert).context("invalid additional trust anchor")?;
}
}
Ok(roots)
}
/// Explicit backend-only escape hatch. `tls_verify=false` means that the backend
/// certificate is not authenticated at all: chain/name checks and certificate
/// signature parsing are skipped. TLS transport encryption and the normal
/// address/DNS/loop policy remain in force.
///
/// Do not delegate CertificateVerify to WebPkiServerVerifier here. webpki parses
/// the presented X.509 certificate even after `verify_server_cert()` accepted it,
/// which rejects legacy X.509 v1 appliance certificates with
/// `UnsupportedCertVersion`. That made `tls_verify=false` only a partial bypass.
#[derive(Debug)]
struct BackendCertificateVerificationDisabled {
supported_schemes: Vec<SignatureScheme>,
}
impl ServerCertVerifier for BackendCertificateVerificationDisabled {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.supported_schemes.clone()
}
}
pub fn client(backend: &Backend, policy: AddressPolicy, websocket: bool) -> Result<ProxyClient> {
client_with_keepalive(
backend,
policy,
websocket,
&crate::balance::Keepalive::default(),
)
}
pub fn client_with_keepalive(
backend: &Backend,
policy: AddressPolicy,
websocket: bool,
keepalive: &crate::balance::Keepalive,
) -> Result<ProxyClient> {
client_with_timeout(backend, policy, websocket, keepalive, CONNECT_TIMEOUT)
}
pub fn client_with_timeout(
backend: &Backend,
policy: AddressPolicy,
websocket: bool,
keepalive: &crate::balance::Keepalive,
timeout: Duration,
) -> Result<ProxyClient> {
// HTTPS "http2" prefers h2 but always offers HTTP/1.1. Cleartext http2 remains
// explicit h2c prior knowledge; never replay a failed TLS request to downgrade.
let is_https = url::Url::parse(&backend.url)?.scheme() == "https";
let protocol = if websocket {
UpstreamProtocol::Http1
} else if backend.protocol == UpstreamProtocol::Http2 && is_https {
UpstreamProtocol::Auto
} else {
backend.protocol
};
let provider = Arc::new(rustls::crypto::ring::default_provider());
let trust_roots = if backend.tls_verify {
roots(backend)?
} else {
// tls_ca_pem is intentionally ignored when verification is disabled.
public_roots()
};
let mut config = ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()?
.with_root_certificates(trust_roots)
.with_no_client_auth();
if !backend.tls_verify {
let supported_schemes = provider
.signature_verification_algorithms
.supported_schemes();
config.dangerous().set_certificate_verifier(Arc::new(
BackendCertificateVerificationDisabled { supported_schemes },
));
}
let connect_host = if backend.tls_server_name.is_some() {
Some(
url::Url::parse(&backend.url)?
.host_str()
.context("backend host missing")?
.trim_start_matches('[')
.trim_end_matches(']')
.to_owned(),
)
} else {
None
};
let mut http = HttpConnector::new_with_resolver(GuardedResolver {
policy: policy.clone(),
connect_host,
});
http.enforce_http(false);
http.set_connect_timeout(Some(timeout));
http.set_nodelay(true);
http.set_keepalive(
keepalive
.enabled
.then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)),
);
let builder = HttpsConnectorBuilder::new()
.with_tls_config(config)
.https_or_http();
let inner = match protocol {
UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http),
UpstreamProtocol::Http2 => builder.enable_http2().wrap_connector(http),
UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http),
};
let mut builder = Client::builder(TokioExecutor::new());
builder
// Header field names are case-insensitive by HTTP specification, but some
// legacy/custom origins parse HTTP/1 request headers case-sensitively.
// Hyper writes lowercase names by default; title-case them on the wire for
// broad origin compatibility. This setting does not affect HTTP/2.
.http1_title_case_headers(true)
.pool_timer(TokioTimer::new())
.pool_idle_timeout(Duration::from_secs(keepalive.idle_timeout_seconds))
.pool_max_idle_per_host(if keepalive.enabled {
keepalive.max_idle_per_host
} else {
0
})
.retry_canceled_requests(false)
.http2_only(protocol == UpstreamProtocol::Http2)
.timer(TokioTimer::new())
.http2_keep_alive_interval(
keepalive
.enabled
.then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)),
)
.http2_keep_alive_timeout(Duration::from_secs(10))
.http2_keep_alive_while_idle(false);
Ok(builder.build(TimedConnector {
inner,
policy,
timeout,
}))
}
/// A bounded raw TCP liveness check, using the same configured-origin address
/// policy as normal requests. Every DNS answer is checked before any connection.
pub async fn tcp_probe(origin: &str, policy: &AddressPolicy) -> Result<()> {
let url = url::Url::parse(origin)?;
let host = url
.host_str()
.context("probe host missing")?
.trim_start_matches('[')
.trim_end_matches(']');
let port = url.port_or_known_default().context("probe port missing")?;
let addresses = if let Ok(ip) = host.parse::<IpAddr>() {
policy.check(ip)?;
vec![SocketAddr::new(ip, port)]
} else {
let mut resolver = GuardedResolver {
policy: policy.clone(),
connect_host: None,
};
resolver
.call(host.parse::<Name>()?)
.await?
.map(|mut a| {
a.set_port(port);
a
})
.collect()
};
for address in addresses {
if tokio::net::TcpStream::connect(address).await.is_ok() {
return Ok(());
}
}
anyhow::bail!("no approved backend address accepted TCP")
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use http::{Request, Response, Version};
use http_body_util::{BodyExt, Full};
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
use std::convert::Infallible;
#[test]
fn disabled_backend_certificate_verification_does_not_parse_certificate() {
let provider = rustls::crypto::ring::default_provider();
let verifier = BackendCertificateVerificationDisabled {
supported_schemes: provider
.signature_verification_algorithms
.supported_schemes(),
};
let malformed_or_legacy = CertificateDer::from(vec![0x30, 0x00]);
let server_name = ServerName::try_from("legacy.example").unwrap();
assert!(verifier
.verify_server_cert(
&malformed_or_legacy,
&[],
&server_name,
&[],
UnixTime::since_unix_epoch(Duration::from_secs(0)),
)
.is_ok());
}
#[test]
fn resolved_listener_addresses_and_wildcard_interfaces_are_blocked() {
let policy = AddressPolicy {
allow_private: true,
port: 443,
listeners: vec!["0.0.0.0:443".parse().unwrap()],
local_ips: vec!["192.168.1.5".parse().unwrap()],
interfaces_known: true,
..Default::default()
};
for ip in ["127.0.0.1", "192.168.1.5", "::ffff:127.0.0.1"] {
assert!(policy.check(ip.parse().unwrap()).is_err());
}
assert!(policy.check("93.184.216.34".parse().unwrap()).is_ok());
let answers = vec![
"93.184.216.34:0".parse().unwrap(),
"192.168.1.5:0".parse().unwrap(),
];
assert!(policy.validate_answers(answers.into_iter()).is_err());
}
#[test]
fn explicit_risk_acceptance_allows_local_listener_but_not_private_policy_bypass() {
let mut policy = AddressPolicy {
allow_private: true,
accept_local_listener_risk: true,
port: 9090,
listeners: vec!["127.0.0.1:9090".parse().unwrap()],
local_ips: vec!["127.0.0.1".parse().unwrap()],
interfaces_known: true,
..Default::default()
};
assert!(policy.check("127.0.0.1".parse().unwrap()).is_ok());
policy.allow_private = false;
assert!(policy.check("127.0.0.1".parse().unwrap()).is_err());
}
#[test]
fn wildcard_listener_allows_distinct_remote_private_backend_on_same_port() {
let policy = AddressPolicy {
allow_private: true,
port: 80,
listeners: vec!["0.0.0.0:80".parse().unwrap()],
local_ips: vec!["10.87.2.93".parse().unwrap()],
interfaces_known: true,
..Default::default()
};
assert!(policy.check("10.87.20.2".parse().unwrap()).is_ok());
}
#[tokio::test]
async fn explicit_h2c_backend_uses_http2_prior_knowledge() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let service = service_fn(|request: Request<hyper::body::Incoming>| async move {
assert_eq!(request.version(), Version::HTTP_2);
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"h2-ok"))))
});
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(stream), service)
.await;
});
let backend: Backend = serde_json::from_value(serde_json::json!({
"id": "h2",
"url": format!("http://{address}"),
"protocol": "http2",
"allow_private": true
}))
.unwrap();
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder()
.version(Version::HTTP_2)
.uri(format!("http://{address}/probe"))
.body(Body::empty())
.unwrap();
let response = client.request(request).await.unwrap();
assert_eq!(response.version(), Version::HTTP_2);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"h2-ok")
);
server.abort();
}
#[test]
fn mixed_dns_answer_fails_closed() {
let policy = AddressPolicy {
allow_private: false,
allowlist: vec![],
..Default::default()
};
let addresses = vec!["1.1.1.1:0".parse().unwrap(), "127.0.0.1:0".parse().unwrap()];
assert!(policy.validate_answers(addresses.into_iter()).is_err());
}
#[test]
fn allowlist_restricts_even_explicit_private_backends() {
let policy = AddressPolicy {
allow_private: true,
allowlist: vec!["10.2.0.0/16".parse().unwrap()],
..Default::default()
};
assert!(policy.check("10.2.1.1".parse().unwrap()).is_ok());
for ip in [
"10.3.1.1",
"127.0.0.1",
"169.254.169.254",
"100.100.100.200",
] {
assert!(policy.check(ip.parse().unwrap()).is_err(), "{ip}");
}
}
#[test]
fn mapped_addresses_cannot_bypass_ipv4_allowlist() {
let policy = AddressPolicy {
allow_private: true,
allowlist: vec!["127.0.0.1/32".parse().unwrap()],
..Default::default()
};
assert!(policy.check("::ffff:127.0.0.1".parse().unwrap()).is_ok());
assert!(policy
.check("::ffff:169.254.169.254".parse().unwrap())
.is_err());
}
#[test]
fn empty_and_oversized_dns_answers_are_rejected() {
let policy = AddressPolicy {
allow_private: true,
allowlist: vec![],
..Default::default()
};
assert!(policy.validate_answers(std::iter::empty()).is_err());
let addresses = (1..=33).map(|n| format!("10.0.0.{n}:0").parse().unwrap());
assert!(policy.validate_answers(addresses).is_err());
}
}
/// Enumerate local interfaces for wildcard-listener loop protection. If unavailable,
/// the fallback checks whether a candidate address is actually bindable on this host.
#[cfg(unix)]
fn local_addresses() -> Option<Vec<IpAddr>> {
let mut first: *mut libc::ifaddrs = std::ptr::null_mut();
// SAFETY: getifaddrs owns the returned linked list until freeifaddrs; family
// is inspected before casting each non-null sockaddr to its matching type.
unsafe {
if libc::getifaddrs(&mut first) != 0 {
return None;
}
let mut result = Vec::new();
let mut current = first;
while !current.is_null() {
let item = &*current;
if !item.ifa_addr.is_null() {
match (*item.ifa_addr).sa_family as i32 {
libc::AF_INET => {
let addr = &*(item.ifa_addr as *const libc::sockaddr_in);
result.push(IpAddr::V4(std::net::Ipv4Addr::from(
addr.sin_addr.s_addr.to_ne_bytes(),
)));
}
libc::AF_INET6 => {
let addr = &*(item.ifa_addr as *const libc::sockaddr_in6);
result.push(IpAddr::V6(std::net::Ipv6Addr::from(addr.sin6_addr.s6_addr)));
}
_ => {}
}
}
current = item.ifa_next;
}
libc::freeifaddrs(first);
Some(result)
}
}
#[cfg(not(unix))]
fn local_addresses() -> Option<Vec<IpAddr>> {
None
}
/// Fallback used when interface enumeration is unavailable. Binding an ephemeral
/// UDP socket to an address succeeds only when the OS considers that address local
/// (without non-local/freebind socket options, which Rust does not enable here).
fn locally_bindable(ip: IpAddr) -> bool {
std::net::UdpSocket::bind(SocketAddr::new(ip, 0)).is_ok()
}