mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 06:00:20 +08:00
fix(network): tolerate synthetic DNS for trusted origins
This commit is contained in:
@@ -221,7 +221,14 @@ async fn build_bark_push_client_and_url(
|
||||
.take(MAX_BARK_RESOLVED_ADDRESSES)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
validate_bark_resolved_addresses(&addresses, env_flag_enabled(BARK_ALLOW_PRIVATE_TARGETS_ENV))?;
|
||||
let allow_benchmarking_ip = push_url.scheme() == "https"
|
||||
&& push_url.port_or_known_default() == Some(443)
|
||||
&& host.eq_ignore_ascii_case("api.day.app");
|
||||
validate_bark_resolved_addresses(
|
||||
&addresses,
|
||||
env_flag_enabled(BARK_ALLOW_PRIVATE_TARGETS_ENV),
|
||||
allow_benchmarking_ip,
|
||||
)?;
|
||||
|
||||
push_url
|
||||
.path_segments_mut()
|
||||
@@ -261,6 +268,7 @@ fn validate_bark_transport_policy(url: &url::Url, allow_http: bool) -> Result<()
|
||||
fn validate_bark_resolved_addresses(
|
||||
addresses: &[SocketAddr],
|
||||
allow_private: bool,
|
||||
allow_benchmarking_ip: bool,
|
||||
) -> Result<(), GatewayError> {
|
||||
if addresses.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
@@ -268,9 +276,11 @@ fn validate_bark_resolved_addresses(
|
||||
));
|
||||
}
|
||||
if !allow_private
|
||||
&& addresses
|
||||
.iter()
|
||||
.any(|address| aether_http::is_private_or_reserved_ip(address.ip()))
|
||||
&& addresses.iter().any(|address| {
|
||||
aether_http::is_private_or_reserved_ip(address.ip())
|
||||
&& !(allow_benchmarking_ip
|
||||
&& aether_http::is_ipv4_benchmarking_fake_ip(address.ip()))
|
||||
})
|
||||
{
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"Bark 服务器解析到私有或保留地址;如确需内网自建服务,请显式设置 {BARK_ALLOW_PRIVATE_TARGETS_ENV}=true"
|
||||
@@ -428,8 +438,21 @@ mod tests {
|
||||
#[test]
|
||||
fn bark_private_targets_require_explicit_opt_in() {
|
||||
let private = [SocketAddr::from(([127, 0, 0, 1], 443))];
|
||||
assert!(validate_bark_resolved_addresses(&private, false).is_err());
|
||||
assert!(validate_bark_resolved_addresses(&private, true).is_ok());
|
||||
assert!(validate_bark_resolved_addresses(&private, false, false).is_err());
|
||||
assert!(validate_bark_resolved_addresses(&private, true, false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_builtin_server_allows_benchmarking_ip_only_with_https_default_port() {
|
||||
let fake = [SocketAddr::from(([198, 18, 75, 234], 443))];
|
||||
assert!(validate_bark_resolved_addresses(&fake, false, true).is_ok());
|
||||
assert!(validate_bark_resolved_addresses(
|
||||
&[fake[0], SocketAddr::from(([127, 0, 0, 1], 443))],
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_bark_resolved_addresses(&fake, false, false).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -22,8 +22,8 @@ use aether_contracts::{
|
||||
};
|
||||
use aether_data::repository::proxy_nodes::ProxyNodeTrafficMutation;
|
||||
use aether_http::{
|
||||
apply_http_client_config, is_https_or_loopback_http_url, is_private_or_reserved_ip,
|
||||
HttpClientConfig,
|
||||
apply_http_client_config, is_https_or_loopback_http_url, is_ipv4_benchmarking_fake_ip,
|
||||
is_private_or_reserved_ip, HttpClientConfig,
|
||||
};
|
||||
use aether_runtime::{MetricKind, MetricSample};
|
||||
use axum::body::Bytes;
|
||||
@@ -458,6 +458,74 @@ struct ExecutionSafeDnsResolver;
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct ExecutionSafeHyperDnsResolver;
|
||||
|
||||
// Local DNS interception tools may use RFC 2544's 198.18.0.0/15 range for
|
||||
// synthetic answers. This exception is deliberately an allowlist rather
|
||||
// than a property of the address range itself: a custom provider hostname
|
||||
// must not be able to turn a local synthetic mapping into an SSRF primitive.
|
||||
// Keep this list limited to origins that Aether constructs as built-in
|
||||
// provider/model-fetch targets. In particular, do not use a
|
||||
// suffix match for ordinary hosts (for example, `evil.chatgpt.com`).
|
||||
const TRUSTED_EXECUTION_BENCHMARKING_DNS_EXACT_HOSTS: &[&str] = &[
|
||||
"aiplatform.googleapis.com",
|
||||
"antigravity.googleapis.com",
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"api.deepseek.com",
|
||||
"chatgpt.com",
|
||||
"cloudcode-pa.googleapis.com",
|
||||
"daily-cloudcode-pa.googleapis.com",
|
||||
"daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"dashscope.aliyuncs.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"grok.com",
|
||||
"open.bigmodel.cn",
|
||||
"server.codeium.com",
|
||||
];
|
||||
|
||||
/// Return whether `host` is one of the fixed provider origins for which a
|
||||
/// local RFC-2544 synthetic answer can be accepted. The resolver receives only
|
||||
/// a hostname (not the URL scheme/path), so all policy that can be expressed
|
||||
/// here is intentionally host based. URL validation still requires HTTPS for
|
||||
/// non-loopback upstreams before this resolver is used.
|
||||
fn execution_host_allows_benchmarking_dns_answer(host: &str) -> bool {
|
||||
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
if TRUSTED_EXECUTION_BENCHMARKING_DNS_EXACT_HOSTS
|
||||
.iter()
|
||||
.any(|trusted| *trusted == host)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vertex service-account requests use `<region>-aiplatform.googleapis.com`.
|
||||
// Keep the interpolated region to one DNS label and reuse the provider's
|
||||
// existing conservative region syntax validator.
|
||||
if let Some(region) = host.strip_suffix("-aiplatform.googleapis.com") {
|
||||
return looks_like_cloud_region_label(region);
|
||||
}
|
||||
|
||||
// Kiro uses q.<region>.amazonaws.com. Match exactly that three-label
|
||||
// service shape; this intentionally does not allow arbitrary AWS
|
||||
// subdomains or lookalikes such as q.us-east-1.evil.amazonaws.com.
|
||||
let labels = host.split('.').collect::<Vec<_>>();
|
||||
labels.len() == 4
|
||||
&& labels[0] == "q"
|
||||
&& labels[2] == "amazonaws"
|
||||
&& labels[3] == "com"
|
||||
&& looks_like_cloud_region_label(labels[1])
|
||||
}
|
||||
|
||||
fn looks_like_cloud_region_label(value: &str) -> bool {
|
||||
value.len() >= 3
|
||||
&& value.len() <= 63
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& !value.starts_with('-')
|
||||
&& !value.ends_with('-')
|
||||
&& value.contains('-')
|
||||
&& value.bytes().any(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn dns_host_explicitly_allows_loopback(host: &str) -> bool {
|
||||
let host = host.trim_end_matches('.');
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
@@ -470,6 +538,14 @@ fn dns_host_explicitly_allows_loopback(host: &str) -> bool {
|
||||
fn validate_execution_dns_answers(
|
||||
host: &str,
|
||||
addresses: Vec<SocketAddr>,
|
||||
) -> Result<Vec<SocketAddr>, std::io::Error> {
|
||||
validate_execution_dns_answers_with_policy(host, addresses, true)
|
||||
}
|
||||
|
||||
fn validate_execution_dns_answers_with_policy(
|
||||
host: &str,
|
||||
addresses: Vec<SocketAddr>,
|
||||
allow_trusted_benchmarking_dns_answer: bool,
|
||||
) -> Result<Vec<SocketAddr>, std::io::Error> {
|
||||
if addresses.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
@@ -479,11 +555,14 @@ fn validate_execution_dns_answers(
|
||||
}
|
||||
|
||||
let allows_loopback = dns_host_explicitly_allows_loopback(host);
|
||||
let allows_benchmarking_dns_answer = allow_trusted_benchmarking_dns_answer
|
||||
&& execution_host_allows_benchmarking_dns_answer(host);
|
||||
let unsafe_answer = addresses.iter().any(|address| {
|
||||
if allows_loopback {
|
||||
!address.ip().is_loopback()
|
||||
} else {
|
||||
is_private_or_reserved_ip(address.ip())
|
||||
&& !(allows_benchmarking_dns_answer && is_ipv4_benchmarking_fake_ip(address.ip()))
|
||||
}
|
||||
});
|
||||
if unsafe_answer {
|
||||
@@ -497,12 +576,13 @@ fn validate_execution_dns_answers(
|
||||
}
|
||||
|
||||
async fn resolve_execution_dns_addresses(host: &str) -> Result<Vec<SocketAddr>, std::io::Error> {
|
||||
resolve_execution_target_addresses(host, 0).await
|
||||
resolve_execution_target_addresses_with_policy(host, 0, true).await
|
||||
}
|
||||
|
||||
async fn resolve_execution_target_addresses(
|
||||
async fn resolve_execution_target_addresses_with_policy(
|
||||
host: &str,
|
||||
port: u16,
|
||||
allow_trusted_benchmarking_dns_answer: bool,
|
||||
) -> Result<Vec<SocketAddr>, std::io::Error> {
|
||||
let addresses = if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
vec![SocketAddr::new(ip, port)]
|
||||
@@ -510,7 +590,11 @@ async fn resolve_execution_target_addresses(
|
||||
aether_http::lookup_host_with_limits(host, port, aether_http::DEFAULT_DNS_LOOKUP_TIMEOUT)
|
||||
.await?
|
||||
};
|
||||
validate_execution_dns_answers(host, addresses)
|
||||
validate_execution_dns_answers_with_policy(
|
||||
host,
|
||||
addresses,
|
||||
allow_trusted_benchmarking_dns_answer,
|
||||
)
|
||||
}
|
||||
|
||||
impl reqwest::dns::Resolve for ExecutionSafeDnsResolver {
|
||||
@@ -2486,7 +2570,7 @@ async fn connect_direct_h2c_sender_on_current_runtime(
|
||||
let port = upstream.port_or_known_default().ok_or_else(|| {
|
||||
ExecutionRuntimeTransportError::UpstreamRequest("missing h2c upstream port".to_string())
|
||||
})?;
|
||||
let addresses = resolve_execution_target_addresses(host, port)
|
||||
let addresses = resolve_execution_target_addresses_with_policy(host, port, true)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
let message = if error.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
@@ -3232,7 +3316,11 @@ async fn resolve_relay_target_addresses(
|
||||
let port = url.port_or_known_default().ok_or_else(|| {
|
||||
ExecutionRuntimeTransportError::RelayError("tunnel relay URL has no port".to_string())
|
||||
})?;
|
||||
let addresses = resolve_execution_target_addresses(host, port)
|
||||
// Relay destinations remain strict even when their hostname happens to be
|
||||
// an official provider origin. The RFC-2544 compatibility exception is
|
||||
// only for direct provider execution; allowing it here would weaken the
|
||||
// relay SSRF guard.
|
||||
let addresses = resolve_execution_target_addresses_with_policy(host, port, false)
|
||||
.await
|
||||
.map_err(|error| match error.kind() {
|
||||
std::io::ErrorKind::PermissionDenied => ExecutionRuntimeTransportError::RelayError(
|
||||
@@ -5431,6 +5519,61 @@ mod tests {
|
||||
assert!(super::validate_execution_dns_answers("api.example.test", Vec::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_dns_answers_allow_benchmarking_range_only_for_fixed_provider_hosts() {
|
||||
let fake = "198.18.75.234:443".parse().unwrap();
|
||||
for host in [
|
||||
"api.openai.com",
|
||||
"CHATGPT.COM.",
|
||||
"us-central1-aiplatform.googleapis.com",
|
||||
"q.us-east-1.amazonaws.com",
|
||||
] {
|
||||
assert!(
|
||||
super::validate_execution_dns_answers(host, vec![fake]).is_ok(),
|
||||
"fixed provider host should accept a benchmarking DNS answer: {host}"
|
||||
);
|
||||
}
|
||||
|
||||
for host in [
|
||||
"api.example.test",
|
||||
"evil.chatgpt.com",
|
||||
"api.openai.com.evil.test",
|
||||
"q.us-east-1.evil.amazonaws.com",
|
||||
"q.us-east-1.amazonaws.com.attacker.test",
|
||||
"q.localhost.amazonaws.com",
|
||||
"198.18.75.234",
|
||||
] {
|
||||
assert!(
|
||||
super::validate_execution_dns_answers(host, vec![fake]).is_err(),
|
||||
"untrusted or lookalike host must reject a benchmarking DNS answer: {host}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_dns_answers_reject_mixed_private_results_and_strict_relay_policy() {
|
||||
let fake = "198.18.75.234:443".parse().unwrap();
|
||||
let public = "93.184.216.34:443".parse().unwrap();
|
||||
let private = "10.0.0.8:443".parse().unwrap();
|
||||
|
||||
// A trusted host may have a synthetic answer alongside a genuine public
|
||||
// answer, but any real private answer still fails closed.
|
||||
assert!(
|
||||
super::validate_execution_dns_answers("api.openai.com", vec![fake, public]).is_ok()
|
||||
);
|
||||
assert!(
|
||||
super::validate_execution_dns_answers("api.openai.com", vec![fake, private]).is_err()
|
||||
);
|
||||
|
||||
// Tunnel relay resolution opts out of the compatibility exception.
|
||||
assert!(super::validate_execution_dns_answers_with_policy(
|
||||
"api.openai.com",
|
||||
vec![fake],
|
||||
false,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_proxy_url_policy_rejects_non_origin_components() {
|
||||
for rejected in [
|
||||
|
||||
@@ -134,7 +134,7 @@ fn validate_admin_external_models_source_addresses(
|
||||
&& addresses.iter().any(|address| {
|
||||
aether_http::is_private_or_reserved_ip(address.ip())
|
||||
&& !(is_official_external_models_catalog_url(url)
|
||||
&& is_ipv4_benchmarking_fake_ip(address.ip()))
|
||||
&& aether_http::is_ipv4_benchmarking_fake_ip(address.ip()))
|
||||
})
|
||||
{
|
||||
return Err(GatewayError::Internal(
|
||||
@@ -157,16 +157,6 @@ fn is_official_external_models_catalog_url(url: &url::Url) -> bool {
|
||||
&& url.fragment().is_none()
|
||||
}
|
||||
|
||||
fn is_ipv4_benchmarking_fake_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
let octets = ip.octets();
|
||||
octets[0] == 198 && (18..=19).contains(&octets[1])
|
||||
}
|
||||
IpAddr::V6(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_admin_external_models_source(
|
||||
raw_url: &str,
|
||||
allow_insecure_test_target: bool,
|
||||
|
||||
@@ -90,6 +90,12 @@ impl Resolve for SafeUpdateDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let host = name.as_str().trim_end_matches('.').to_ascii_lowercase();
|
||||
let allow_private = self.private_allowed_host.as_deref() == Some(host.as_str());
|
||||
// Transparent DNS proxies may use RFC 2544's 198.18.0.0/15 benchmark
|
||||
// range as a synthetic address. Update destinations are compiled-in
|
||||
// GitHub hosts, so accepting that range
|
||||
// for those exact hosts preserves proxy compatibility without opening
|
||||
// the resolver to arbitrary custom destinations.
|
||||
let allow_benchmarking_ip = is_trusted_update_host(&host);
|
||||
Box::pin(async move {
|
||||
let addresses = aether_http::lookup_host_with_limits(
|
||||
host.as_str(),
|
||||
@@ -98,9 +104,11 @@ impl Resolve for SafeUpdateDnsResolver {
|
||||
)
|
||||
.await
|
||||
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })?;
|
||||
validate_update_resolved_addrs(&addresses, allow_private).map_err(|message| {
|
||||
Box::new(std::io::Error::other(message)) as Box<dyn std::error::Error + Send + Sync>
|
||||
})?;
|
||||
validate_update_resolved_addrs(&addresses, allow_private, allow_benchmarking_ip)
|
||||
.map_err(|message| {
|
||||
Box::new(std::io::Error::other(message))
|
||||
as Box<dyn std::error::Error + Send + Sync>
|
||||
})?;
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
@@ -109,20 +117,32 @@ impl Resolve for SafeUpdateDnsResolver {
|
||||
fn validate_update_resolved_addrs(
|
||||
addresses: &[SocketAddr],
|
||||
allow_private: bool,
|
||||
allow_benchmarking_ip: bool,
|
||||
) -> Result<(), &'static str> {
|
||||
if addresses.is_empty() {
|
||||
return Err("update DNS resolution returned no addresses");
|
||||
}
|
||||
if !allow_private
|
||||
&& addresses
|
||||
.iter()
|
||||
.any(|address| aether_http::is_private_or_reserved_ip(address.ip()))
|
||||
&& addresses.iter().any(|address| {
|
||||
aether_http::is_private_or_reserved_ip(address.ip())
|
||||
&& !(allow_benchmarking_ip
|
||||
&& aether_http::is_ipv4_benchmarking_fake_ip(address.ip()))
|
||||
})
|
||||
{
|
||||
return Err("update DNS resolution returned a private or reserved address");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_trusted_update_host(host: &str) -> bool {
|
||||
host.eq_ignore_ascii_case("github.com")
|
||||
|| host.eq_ignore_ascii_case("api.github.com")
|
||||
|| host.eq_ignore_ascii_case("objects.githubusercontent.com")
|
||||
|| host.ends_with(".objects.githubusercontent.com")
|
||||
|| host.eq_ignore_ascii_case("release-assets.githubusercontent.com")
|
||||
|| host.ends_with(".release-assets.githubusercontent.com")
|
||||
}
|
||||
|
||||
pub(crate) fn is_trusted_update_url(url: &url::Url) -> bool {
|
||||
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
||||
return false;
|
||||
@@ -130,12 +150,7 @@ pub(crate) fn is_trusted_update_url(url: &url::Url) -> bool {
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
host.eq_ignore_ascii_case("github.com")
|
||||
|| host.eq_ignore_ascii_case("api.github.com")
|
||||
|| host.eq_ignore_ascii_case("objects.githubusercontent.com")
|
||||
|| host.ends_with(".objects.githubusercontent.com")
|
||||
|| host.eq_ignore_ascii_case("release-assets.githubusercontent.com")
|
||||
|| host.ends_with(".release-assets.githubusercontent.com")
|
||||
is_trusted_update_host(host)
|
||||
}
|
||||
|
||||
fn update_proxy_url_from_env() -> Option<String> {
|
||||
@@ -165,7 +180,10 @@ fn read_nonempty_env_value(keys: &[&str]) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_trusted_update_url, update_proxy_host, validate_update_resolved_addrs};
|
||||
use super::{
|
||||
is_trusted_update_host, is_trusted_update_url, update_proxy_host,
|
||||
validate_update_resolved_addrs,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[test]
|
||||
@@ -193,11 +211,27 @@ mod tests {
|
||||
let public = "8.8.8.8:443".parse::<SocketAddr>().unwrap();
|
||||
let private = "127.0.0.1:443".parse::<SocketAddr>().unwrap();
|
||||
|
||||
assert!(validate_update_resolved_addrs(&[public], false).is_ok());
|
||||
assert!(validate_update_resolved_addrs(&[private], false).is_err());
|
||||
assert!(validate_update_resolved_addrs(&[public, private], false).is_err());
|
||||
assert!(validate_update_resolved_addrs(&[private], true).is_ok());
|
||||
assert!(validate_update_resolved_addrs(&[], false).is_err());
|
||||
assert!(validate_update_resolved_addrs(&[public], false, false).is_ok());
|
||||
assert!(validate_update_resolved_addrs(&[private], false, false).is_err());
|
||||
assert!(validate_update_resolved_addrs(&[public, private], false, false).is_err());
|
||||
assert!(validate_update_resolved_addrs(&[private], true, false).is_ok());
|
||||
assert!(validate_update_resolved_addrs(&[], false, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_dns_allows_benchmarking_ip_only_for_trusted_github_hosts() {
|
||||
let fake = "198.18.75.234:443".parse::<SocketAddr>().unwrap();
|
||||
assert!(validate_update_resolved_addrs(&[fake], false, true).is_ok());
|
||||
assert!(validate_update_resolved_addrs(
|
||||
&[fake, "127.0.0.1:443".parse().unwrap()],
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_update_resolved_addrs(&[fake], false, false).is_err());
|
||||
assert!(is_trusted_update_host("api.github.com"));
|
||||
assert!(is_trusted_update_host("foo.objects.githubusercontent.com"));
|
||||
assert!(!is_trusted_update_host("github.com.evil.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -37,14 +37,17 @@ fn build_server_chan_client() -> Result<reqwest::Client, reqwest::Error> {
|
||||
.build()
|
||||
}
|
||||
|
||||
fn validate_server_chan_resolved_addresses(addresses: &[SocketAddr]) -> Result<(), &'static str> {
|
||||
fn validate_server_chan_resolved_addresses(
|
||||
addresses: &[SocketAddr],
|
||||
allow_benchmarking_ip: bool,
|
||||
) -> Result<(), &'static str> {
|
||||
if addresses.is_empty() {
|
||||
return Err("Server Chan API DNS resolution returned no addresses");
|
||||
}
|
||||
if addresses
|
||||
.iter()
|
||||
.any(|address| aether_http::is_private_or_reserved_ip(address.ip()))
|
||||
{
|
||||
if addresses.iter().any(|address| {
|
||||
aether_http::is_private_or_reserved_ip(address.ip())
|
||||
&& !(allow_benchmarking_ip && aether_http::is_ipv4_benchmarking_fake_ip(address.ip()))
|
||||
}) {
|
||||
return Err("Server Chan API resolved to a private or reserved address");
|
||||
}
|
||||
Ok(())
|
||||
@@ -65,7 +68,7 @@ async fn build_pinned_server_chan_client() -> Result<reqwest::Client, &'static s
|
||||
std::io::ErrorKind::TimedOut => "Server Chan API DNS resolution timed out",
|
||||
_ => "Server Chan API DNS resolution failed",
|
||||
})?;
|
||||
validate_server_chan_resolved_addresses(&addresses)?;
|
||||
validate_server_chan_resolved_addresses(&addresses, true)?;
|
||||
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
@@ -480,21 +483,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn server_chan_dns_answers_must_be_public() {
|
||||
assert!(
|
||||
validate_server_chan_resolved_addresses(&[SocketAddr::from(([1, 1, 1, 1], 443)),])
|
||||
.is_ok()
|
||||
);
|
||||
assert!(validate_server_chan_resolved_addresses(
|
||||
&[SocketAddr::from(([1, 1, 1, 1], 443))],
|
||||
false,
|
||||
)
|
||||
.is_ok());
|
||||
for address in [
|
||||
SocketAddr::from(([127, 0, 0, 1], 443)),
|
||||
SocketAddr::from(([10, 0, 0, 1], 443)),
|
||||
SocketAddr::from(([169, 254, 169, 254], 443)),
|
||||
] {
|
||||
assert!(
|
||||
validate_server_chan_resolved_addresses(&[address]).is_err(),
|
||||
validate_server_chan_resolved_addresses(&[address], false).is_err(),
|
||||
"private Server Chan DNS answer should be rejected: {address}"
|
||||
);
|
||||
}
|
||||
assert!(validate_server_chan_resolved_addresses(&[]).is_err());
|
||||
assert!(validate_server_chan_resolved_addresses(&[], false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_chan_dns_allows_benchmarking_ip_for_builtin_host() {
|
||||
let fake = SocketAddr::from(([198, 18, 75, 234], 443));
|
||||
assert!(validate_server_chan_resolved_addresses(&[fake], true).is_ok());
|
||||
assert!(validate_server_chan_resolved_addresses(
|
||||
&[fake, SocketAddr::from(([127, 0, 0, 1], 443))],
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_server_chan_resolved_addresses(&[fake], false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -162,6 +162,11 @@ struct SafeGithubDnsResolver;
|
||||
impl Resolve for SafeGithubDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let host = name.as_str().trim_end_matches('.').to_ascii_lowercase();
|
||||
// Transparent DNS interception may map public domains into RFC 2544's
|
||||
// 198.18.0.0/15 benchmark range. This resolver is used exclusively for
|
||||
// built-in GitHub update hosts, so permit that synthetic range only for
|
||||
// the same trusted host set used by redirect validation.
|
||||
let allow_benchmarking_ip = is_trusted_github_host(&host);
|
||||
Box::pin(async move {
|
||||
let addresses = aether_http::lookup_host_with_limits(
|
||||
host.as_str(),
|
||||
@@ -170,27 +175,54 @@ impl Resolve for SafeGithubDnsResolver {
|
||||
)
|
||||
.await
|
||||
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })?;
|
||||
validate_github_resolved_addrs(&addresses).map_err(|message| {
|
||||
Box::new(std::io::Error::other(message)) as Box<dyn std::error::Error + Send + Sync>
|
||||
})?;
|
||||
validate_github_resolved_addrs_with_fake_ip(&addresses, allow_benchmarking_ip)
|
||||
.map_err(|message| {
|
||||
Box::new(std::io::Error::other(message))
|
||||
as Box<dyn std::error::Error + Send + Sync>
|
||||
})?;
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn validate_github_resolved_addrs(addresses: &[SocketAddr]) -> Result<(), &'static str> {
|
||||
validate_github_resolved_addrs_with_fake_ip(addresses, false)
|
||||
}
|
||||
|
||||
fn validate_github_resolved_addrs_with_fake_ip(
|
||||
addresses: &[SocketAddr],
|
||||
allow_benchmarking_ip: bool,
|
||||
) -> Result<(), &'static str> {
|
||||
if addresses.is_empty() {
|
||||
return Err("GitHub DNS resolution returned no addresses");
|
||||
}
|
||||
if addresses
|
||||
.iter()
|
||||
.any(|address| aether_http::is_private_or_reserved_ip(address.ip()))
|
||||
{
|
||||
if addresses.iter().any(|address| {
|
||||
aether_http::is_private_or_reserved_ip(address.ip())
|
||||
&& !(allow_benchmarking_ip && aether_http::is_ipv4_benchmarking_fake_ip(address.ip()))
|
||||
}) {
|
||||
return Err("GitHub DNS resolution returned a private or reserved address");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_trusted_github_host(host: &str) -> bool {
|
||||
host.eq_ignore_ascii_case("github.com")
|
||||
|| host.eq_ignore_ascii_case("api.github.com")
|
||||
|| host.eq_ignore_ascii_case("objects.githubusercontent.com")
|
||||
|| host.ends_with(".objects.githubusercontent.com")
|
||||
|| host.eq_ignore_ascii_case("release-assets.githubusercontent.com")
|
||||
|| host.ends_with(".release-assets.githubusercontent.com")
|
||||
}
|
||||
|
||||
fn is_trusted_github_download_host(host: &str) -> bool {
|
||||
host.eq_ignore_ascii_case("github.com")
|
||||
|| host.eq_ignore_ascii_case("objects.githubusercontent.com")
|
||||
|| host.ends_with(".objects.githubusercontent.com")
|
||||
|| host.eq_ignore_ascii_case("release-assets.githubusercontent.com")
|
||||
|| host.ends_with(".release-assets.githubusercontent.com")
|
||||
}
|
||||
|
||||
fn is_trusted_github_download_url(url: &url::Url) -> bool {
|
||||
if url.scheme() != "https"
|
||||
|| !url.username().is_empty()
|
||||
@@ -203,11 +235,7 @@ fn is_trusted_github_download_url(url: &url::Url) -> bool {
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
host.eq_ignore_ascii_case("github.com")
|
||||
|| host.eq_ignore_ascii_case("objects.githubusercontent.com")
|
||||
|| host.ends_with(".objects.githubusercontent.com")
|
||||
|| host.eq_ignore_ascii_case("release-assets.githubusercontent.com")
|
||||
|| host.ends_with(".release-assets.githubusercontent.com")
|
||||
is_trusted_github_download_host(host)
|
||||
}
|
||||
|
||||
// ── Release fetching ─────────────────────────────────────────────────────────
|
||||
@@ -951,9 +979,10 @@ pub async fn perform_upgrade(version: &str) -> anyhow::Result<()> {
|
||||
mod tests {
|
||||
use super::{
|
||||
append_bounded_download_chunk, atomic_replace_paths, extract_binary,
|
||||
is_trusted_github_download_url, normalize_requested_release_tag, parse_checksum,
|
||||
probe_upgrade_directory_write, restore_tunnel_backup_paths, summarize_remote_error_body,
|
||||
tunnel_release_semver, validate_github_resolved_addrs, validate_upgrade_storage,
|
||||
is_trusted_github_download_url, is_trusted_github_host, normalize_requested_release_tag,
|
||||
parse_checksum, probe_upgrade_directory_write, restore_tunnel_backup_paths,
|
||||
summarize_remote_error_body, tunnel_release_semver, validate_github_resolved_addrs,
|
||||
validate_github_resolved_addrs_with_fake_ip, validate_upgrade_storage,
|
||||
};
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use std::net::SocketAddr;
|
||||
@@ -1009,6 +1038,21 @@ mod tests {
|
||||
assert!(validate_github_resolved_addrs(&[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_dns_allows_benchmarking_ip_only_for_trusted_hosts() {
|
||||
let fake = "198.18.75.234:443".parse::<SocketAddr>().unwrap();
|
||||
assert!(validate_github_resolved_addrs_with_fake_ip(&[fake], true).is_ok());
|
||||
assert!(validate_github_resolved_addrs_with_fake_ip(
|
||||
&[fake, "127.0.0.1:443".parse().unwrap()],
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_github_resolved_addrs_with_fake_ip(&[fake], false).is_err());
|
||||
assert!(is_trusted_github_host("api.github.com"));
|
||||
assert!(is_trusted_github_host("foo.objects.githubusercontent.com"));
|
||||
assert!(!is_trusted_github_host("github.com.evil.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_download_bytes_are_bounded_without_trusting_content_length() {
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user