From c005700a7eb0226b4c84025044e30e38c6cdf5d0 Mon Sep 17 00:00:00 2001 From: elky Date: Fri, 4 Sep 2026 17:25:53 +0800 Subject: [PATCH] fix(network): tolerate synthetic DNS for trusted origins --- apps/aether-gateway/src/bark_push.rs | 35 +++- .../src/execution_runtime/transport.rs | 157 +++++++++++++++++- .../handlers/admin/model/external_cache.rs | 12 +- .../admin/system/shared/update_client.rs | 70 ++++++-- apps/aether-gateway/src/server_chan_push.rs | 40 +++-- apps/aether-tunnel/src/setup/upgrade.rs | 74 +++++++-- crates/aether-http/src/header_security.rs | 4 +- 7 files changed, 321 insertions(+), 71 deletions(-) diff --git a/apps/aether-gateway/src/bark_push.rs b/apps/aether-gateway/src/bark_push.rs index ff544efb1..5b3de17dc 100644 --- a/apps/aether-gateway/src/bark_push.rs +++ b/apps/aether-gateway/src/bark_push.rs @@ -221,7 +221,14 @@ async fn build_bark_push_client_and_url( .take(MAX_BARK_RESOLVED_ADDRESSES) .collect::>() }; - 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] diff --git a/apps/aether-gateway/src/execution_runtime/transport.rs b/apps/aether-gateway/src/execution_runtime/transport.rs index d940078c8..d86973ae4 100644 --- a/apps/aether-gateway/src/execution_runtime/transport.rs +++ b/apps/aether-gateway/src/execution_runtime/transport.rs @@ -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 `-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..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::>(); + 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, +) -> Result, std::io::Error> { + validate_execution_dns_answers_with_policy(host, addresses, true) +} + +fn validate_execution_dns_answers_with_policy( + host: &str, + addresses: Vec, + allow_trusted_benchmarking_dns_answer: bool, ) -> Result, 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, 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, std::io::Error> { let addresses = if let Ok(ip) = host.parse::() { 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 [ diff --git a/apps/aether-gateway/src/handlers/admin/model/external_cache.rs b/apps/aether-gateway/src/handlers/admin/model/external_cache.rs index e2457f007..3e7f30e09 100644 --- a/apps/aether-gateway/src/handlers/admin/model/external_cache.rs +++ b/apps/aether-gateway/src/handlers/admin/model/external_cache.rs @@ -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, diff --git a/apps/aether-gateway/src/handlers/admin/system/shared/update_client.rs b/apps/aether-gateway/src/handlers/admin/system/shared/update_client.rs index c7a01fd24..ae1bfe7d6 100644 --- a/apps/aether-gateway/src/handlers/admin/system/shared/update_client.rs +++ b/apps/aether-gateway/src/handlers/admin/system/shared/update_client.rs @@ -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 { Box::new(error) })?; - validate_update_resolved_addrs(&addresses, allow_private).map_err(|message| { - Box::new(std::io::Error::other(message)) as Box - })?; + validate_update_resolved_addrs(&addresses, allow_private, allow_benchmarking_ip) + .map_err(|message| { + Box::new(std::io::Error::other(message)) + as Box + })?; 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 { @@ -165,7 +180,10 @@ fn read_nonempty_env_value(keys: &[&str]) -> Option { #[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::().unwrap(); let private = "127.0.0.1:443".parse::().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::().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] diff --git a/apps/aether-gateway/src/server_chan_push.rs b/apps/aether-gateway/src/server_chan_push.rs index 8a5f8e24e..ab6c0bf21 100644 --- a/apps/aether-gateway/src/server_chan_push.rs +++ b/apps/aether-gateway/src/server_chan_push.rs @@ -37,14 +37,17 @@ fn build_server_chan_client() -> Result { .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 "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] diff --git a/apps/aether-tunnel/src/setup/upgrade.rs b/apps/aether-tunnel/src/setup/upgrade.rs index 6d7b7854a..60b8a494d 100644 --- a/apps/aether-tunnel/src/setup/upgrade.rs +++ b/apps/aether-tunnel/src/setup/upgrade.rs @@ -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 { Box::new(error) })?; - validate_github_resolved_addrs(&addresses).map_err(|message| { - Box::new(std::io::Error::other(message)) as Box - })?; + validate_github_resolved_addrs_with_fake_ip(&addresses, allow_benchmarking_ip) + .map_err(|message| { + Box::new(std::io::Error::other(message)) + as Box + })?; 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::().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(); diff --git a/crates/aether-http/src/header_security.rs b/crates/aether-http/src/header_security.rs index bf77011f9..df69ee609 100644 --- a/crates/aether-http/src/header_security.rs +++ b/crates/aether-http/src/header_security.rs @@ -91,8 +91,8 @@ pub fn is_private_or_reserved_ip(ip: IpAddr) -> bool { /// Return whether an address belongs to RFC 2544's IPv4 benchmarking range. /// -/// Local DNS interception tools (for example, Surge in Fake-IP mode) commonly -/// synthesize answers from `198.18.0.0/15`. The range is intentionally still +/// Local DNS interception tools commonly synthesize answers from +/// `198.18.0.0/15`. The range is intentionally still /// classified as reserved by [`is_private_or_reserved_ip`]; callers may use /// this predicate only when they have independently established that the /// hostname is a trusted, fixed destination. Keeping the predicates separate