use std::net::IpAddr;
use super::{
MAX_ENDPOINT_WEIGHT, MAX_ENDPOINTS,
health_check::{extract_host, is_ssrf_sensitive, is_ssrf_sensitive_hostname},
};
use crate::{
config::{Cluster, InsecureOptions},
connectivity::normalize_mapped_ipv4,
errors::ProxyError,
};
pub(super) fn validate_endpoints(cluster: &Cluster, insecure_options: &InsecureOptions) -> Result<(), ProxyError> {
if cluster.endpoints.is_empty() {
return Err(ProxyError::Config(format!(
"cluster '{}' has no endpoints",
cluster.name
)));
}
if cluster.endpoints.len() > MAX_ENDPOINTS {
return Err(ProxyError::Config(format!(
"cluster '{}' has too many endpoints ({}, max {MAX_ENDPOINTS})",
cluster.name,
cluster.endpoints.len()
)));
}
let mut seen = std::collections::HashSet::with_capacity(cluster.endpoints.len());
for ep in &cluster.endpoints {
validate_endpoint_address(ep.address(), &cluster.name)?;
validate_endpoint_weight(ep.weight(), ep.address(), &cluster.name)?;
if !seen.insert(ep.address()) {
return Err(ProxyError::Config(format!(
"cluster '{}': endpoint '{}' is listed more than once (use 'weight' to bias traffic)",
cluster.name,
ep.address()
)));
}
}
validate_endpoint_ssrf(cluster, insecure_options)
}
fn validate_endpoint_address(addr: &str, cluster_name: &str) -> Result<(), ProxyError> {
if addr.is_empty() {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint address must not be empty"
)));
}
if addr.parse::<std::net::SocketAddr>().is_ok() {
return Ok(());
}
let Some((host, port_str)) = addr.rsplit_once(':') else {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr}' must be 'host:port' with a valid port"
)));
};
if port_str.parse::<u16>().is_err() {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr}' must be 'host:port' with a valid port"
)));
}
validate_endpoint_host(host, addr, cluster_name)
}
fn validate_endpoint_host(host: &str, addr: &str, cluster_name: &str) -> Result<(), ProxyError> {
if host.starts_with('[') != host.ends_with(']') {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr}' has an unbalanced IPv6 bracket (expected '[addr]:port')"
)));
}
let bracketed = host.strip_prefix('[').and_then(|stripped| stripped.strip_suffix(']'));
let host = bracketed.unwrap_or(host);
if host.is_empty() {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr}' has an empty host (expected 'host:port')"
)));
}
if host.parse::<IpAddr>().is_ok() {
return Ok(());
}
if bracketed.is_some() {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr}' is not an IPv6 address (expected '[addr]:port')"
)));
}
let name = host.strip_suffix('.').unwrap_or(host);
praxis_tls::dns::validate_dns_hostname(name).map_err(|err| {
ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr}' is not a valid hostname ({err})"
))
})
}
fn validate_endpoint_weight(weight: u32, addr: &str, cluster_name: &str) -> Result<(), ProxyError> {
if weight == 0 {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr}' has weight 0 (must be >= 1)"
)));
}
if weight > MAX_ENDPOINT_WEIGHT {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr}' has weight {weight} (max {MAX_ENDPOINT_WEIGHT})"
)));
}
Ok(())
}
fn validate_endpoint_ssrf(cluster: &Cluster, insecure_options: &InsecureOptions) -> Result<(), ProxyError> {
if cluster.health_check.is_some() || insecure_options.allow_private_endpoints {
return Ok(());
}
for ep in &cluster.endpoints {
let addr_str = ep.address();
let host = extract_host(addr_str);
reject_ssrf_host(host, &cluster.name, addr_str)?;
}
Ok(())
}
fn reject_ssrf_host(host: &str, cluster_name: &str, addr_str: &str) -> Result<(), ProxyError> {
let sensitive = match host.parse::<IpAddr>() {
Ok(raw) => is_ssrf_sensitive(&normalize_mapped_ipv4(raw)),
Err(_) => is_ssrf_sensitive_hostname(host),
};
if sensitive {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': endpoint '{addr_str}' resolves to a sensitive \
address; set insecure_options.allow_private_endpoints: true to allow"
)));
}
Ok(())
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
mod tests {
use super::super::{MAX_ENDPOINT_WEIGHT, validate_clusters};
use crate::config::{Cluster, Config, InsecureOptions};
#[test]
fn reject_empty_endpoints() {
let clusters = vec![Cluster::with_defaults("empty", vec![])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("cluster 'empty' has no endpoints"));
}
#[test]
fn reject_too_many_endpoints() {
let endpoints: Vec<_> = (0..10_001)
.map(|i| format!("10.0.{}.{}:80", i / 256, i % 256).into())
.collect();
let clusters = vec![Cluster::with_defaults("big", endpoints)];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("too many endpoints"),
"should reject cluster exceeding MAX_ENDPOINTS: {err}"
);
}
#[test]
fn accept_exactly_max_endpoints() {
let endpoints: Vec<_> = (0..10_000)
.map(|i| format!("10.{}.{}.{}:80", i / 65536, (i / 256) % 256, i % 256).into())
.collect();
let clusters = vec![Cluster::with_defaults("big", endpoints)];
validate_clusters(&clusters, &InsecureOptions::default()).expect("exactly MAX_ENDPOINTS should be accepted");
}
#[test]
fn reject_loopback_endpoint_without_health_check() {
let clusters = vec![Cluster::with_defaults("web", vec!["127.0.0.1:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("sensitive address"), "got: {err}");
}
#[test]
fn reject_localhost_hostname_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["localhost:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("sensitive address"), "got: {err}");
}
#[test]
fn reject_metadata_internal_hostname() {
let clusters = vec![Cluster::with_defaults(
"web",
vec!["metadata.google.internal:80".into()],
)];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("sensitive address"), "got: {err}");
}
#[test]
fn reject_ipv6_link_local_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["[fe80::1]:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("sensitive address"), "got: {err}");
}
#[test]
fn allow_private_endpoint_with_override() {
let clusters = vec![Cluster::with_defaults("web", vec!["127.0.0.1:80".into()])];
let opts = InsecureOptions {
allow_private_endpoints: true,
..InsecureOptions::default()
};
validate_clusters(&clusters, &opts).expect("allow_private_endpoints should allow loopback");
}
#[test]
fn ssrf_skip_endpoint_check_when_health_check_present() {
let clusters = vec![Cluster {
health_check: Some(crate::config::HealthCheckConfig {
check_type: crate::config::HealthCheckType::Http,
expected_status: 200,
grpc_service: String::new(),
healthy_threshold: 2,
interval_ms: 5000,
passive_healthy_threshold: None,
passive_unhealthy_threshold: None,
path: "/health".to_owned(),
timeout_ms: 2000,
unhealthy_threshold: 3,
}),
..Cluster::with_defaults("web", vec!["127.0.0.1:80".into()])
}];
let opts = InsecureOptions {
allow_private_health_checks: true,
..InsecureOptions::default()
};
validate_clusters(&clusters, &opts)
.expect("endpoint SSRF defers to health check SSRF when health check present");
}
#[test]
fn accept_rfc1918_endpoint_without_override() {
let clusters = vec![Cluster::with_defaults("web", vec!["10.0.0.1:80".into()])];
validate_clusters(&clusters, &InsecureOptions::default()).expect("RFC 1918 addresses should not be flagged");
}
#[test]
fn accept_public_hostname_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["api.example.com:443".into()])];
validate_clusters(&clusters, &InsecureOptions::default()).expect("public hostnames should not be flagged");
}
#[test]
fn reject_endpoint_missing_port() {
let clusters = vec![Cluster::with_defaults("web", vec!["10.0.0.1".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("host:port"),
"endpoint without port should be rejected: {err}"
);
}
#[test]
fn reject_endpoint_invalid_port() {
let clusters = vec![Cluster::with_defaults("web", vec!["10.0.0.1:99999".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("host:port"),
"endpoint with invalid port should be rejected: {err}"
);
}
#[test]
fn reject_empty_endpoint_address() {
let clusters = vec![Cluster::with_defaults("web", vec!["".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("must not be empty"),
"empty endpoint address should be rejected: {err}"
);
}
#[test]
fn reject_duplicate_endpoint_addresses() {
let clusters = vec![Cluster::with_defaults(
"web",
vec!["10.0.0.1:80".into(), "10.0.0.2:80".into(), "10.0.0.1:80".into()],
)];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("listed more than once"),
"a duplicate endpoint address must be rejected: {err}"
);
}
#[test]
fn reject_empty_host_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec![":80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("empty host"),
"an endpoint with a valid port but empty host must be rejected: {err}"
);
}
#[test]
fn reject_empty_bracketed_host_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["[]:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("empty host"), "got: {err}");
}
#[test]
fn reject_unbalanced_ipv6_bracket_endpoint() {
for addr in ["[::1:80", "::1]:80"] {
let clusters = vec![Cluster::with_defaults("web", vec![addr.into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("unbalanced IPv6 bracket"),
"'{addr}' must be rejected: {err}"
);
}
}
#[test]
fn reject_endpoint_host_with_invalid_characters() {
for addr in ["bad host:80", "my_backend:80", "back$end:80", "-bad.example.com:80"] {
let clusters = vec![Cluster::with_defaults("web", vec![addr.into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("not a valid hostname"),
"'{addr}' can never resolve and must be rejected: {err}"
);
}
}
#[test]
fn reject_bracketed_non_ipv6_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["[abc]:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("not an IPv6 address"),
"brackets must hold an IPv6 literal: {err}"
);
}
#[test]
fn accept_multi_label_hostname_endpoint() {
let clusters = vec![Cluster::with_defaults(
"web",
vec!["backend.svc.example.net:8080".into()],
)];
validate_clusters(&clusters, &InsecureOptions::default()).expect("dotted hostname:port should be accepted");
}
#[test]
fn accept_ipv4_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["10.0.0.1:8080".into()])];
validate_clusters(&clusters, &InsecureOptions::default()).expect("valid IPv4:port should be accepted");
}
#[test]
fn accept_bracketed_ipv6_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["[2001:db8::1]:80".into()])];
validate_clusters(&clusters, &InsecureOptions::default()).expect("bracketed IPv6 should be accepted");
}
#[test]
fn accept_hostname_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["api.example.com:443".into()])];
validate_clusters(&clusters, &InsecureOptions::default()).expect("hostname:port should be accepted");
}
#[test]
fn accept_trailing_dot_fqdn_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["backend.example.com.:80".into()])];
validate_clusters(&clusters, &InsecureOptions::default())
.expect("a fully-qualified name with a trailing dot should be accepted");
}
#[test]
fn reject_dot_only_host_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec![".:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("is not a valid hostname"),
"a lone dot is not a hostname: {err}"
);
}
#[test]
fn reject_ipv4_mapped_ipv6_loopback_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["[::ffff:127.0.0.1]:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("sensitive address"),
"IPv4-mapped IPv6 loopback should be flagged: {err}"
);
}
#[test]
fn reject_zero_weight_endpoint() {
let yaml = r#"
listeners:
- name: web
address: "0.0.0.0:80"
filter_chains: [main]
filter_chains:
- name: main
filters:
- filter: static_response
status: 200
clusters:
- name: "backend"
endpoints:
- address: "10.0.0.1:80"
weight: 0
"#;
let err = Config::from_yaml(yaml).unwrap_err();
assert!(err.to_string().contains("weight 0"), "got: {err}");
}
#[test]
fn reject_overweight_endpoint() {
let yaml = r#"
listeners:
- name: web
address: "0.0.0.0:80"
filter_chains: [main]
filter_chains:
- name: main
filters:
- filter: static_response
status: 200
clusters:
- name: "backend"
endpoints:
- address: "10.0.0.1:80"
weight: 4000000000 # 4 billion replicas if expanded
"#;
let err = Config::from_yaml(yaml).unwrap_err();
assert!(
err.to_string().contains(&format!("max {MAX_ENDPOINT_WEIGHT}")),
"got: {err}"
);
}
#[test]
fn reject_decimal_loopback_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["2130706433:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("sensitive address"),
"decimal 2130706433 (127.0.0.1) should be rejected: {err}"
);
}
#[test]
fn reject_hex_loopback_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["0x7f000001:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("sensitive address"),
"hex 0x7f000001 (127.0.0.1) should be rejected: {err}"
);
}
#[test]
fn reject_octal_dotted_loopback_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["0177.0.0.1:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("sensitive address"),
"octal 0177.0.0.1 (127.0.0.1) should be rejected: {err}"
);
}
#[test]
fn reject_hex_dotted_loopback_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["0x7f.0.0.1:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("sensitive address"),
"hex dotted 0x7f.0.0.1 (127.0.0.1) should be rejected: {err}"
);
}
#[test]
fn reject_decimal_link_local_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["2852039166:80".into()])];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("sensitive address"),
"decimal 2852039166 (169.254.169.254) should be rejected: {err}"
);
}
#[test]
fn accept_decimal_public_ip_endpoint() {
let clusters = vec![Cluster::with_defaults("web", vec!["134744072:80".into()])];
validate_clusters(&clusters, &InsecureOptions::default())
.expect("decimal 134744072 (8.8.8.8) should not be flagged");
}
}