v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
+662
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user