mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
Share provider DNS policy across WebSocket and connection probes, handle bracketed IPv6 literals, and preserve bounded address sets for outbound clients. Bound SMTP DNS and TCP setup with multi-address fallback. Add opt-in trusted proxy DNS for tunnel upstreams while retaining default IP ACLs and origin isolation. Document DNS policy boundaries and verify 809 gateway, tunnel, and HTTP regression tests.
149 lines
5.2 KiB
Rust
149 lines
5.2 KiB
Rust
use std::io;
|
|
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
|
|
use std::time::Duration;
|
|
|
|
/// Maximum number of addresses accepted from one hostname lookup.
|
|
///
|
|
/// A DNS response is attacker-controlled at the resolver boundary. Keeping
|
|
/// the result bounded prevents a pathological answer from forcing an
|
|
/// unbounded vector allocation or an unbounded connect fan-out. Callers still
|
|
/// validate every returned address for their own network policy.
|
|
pub const MAX_DNS_RESOLVED_ADDRESSES: usize = 32;
|
|
|
|
/// Upper bound used by callers that do not have a tighter request deadline.
|
|
pub const DEFAULT_DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
pub fn parse_ip_literal_host(host: &str) -> Option<IpAddr> {
|
|
host.parse().ok().or_else(|| {
|
|
host.strip_prefix('[')?
|
|
.strip_suffix(']')?
|
|
.parse::<Ipv6Addr>()
|
|
.ok()
|
|
.map(IpAddr::V6)
|
|
})
|
|
}
|
|
|
|
/// Resolve a host while bounding both resolver wait time and answer count.
|
|
///
|
|
/// The iterator is consumed one item past the allowed count so an answer set
|
|
/// larger than the policy is rejected rather than silently truncated. This
|
|
/// keeps validation and connection pinning based on the complete, bounded
|
|
/// answer set.
|
|
pub async fn lookup_host_with_limits(
|
|
host: &str,
|
|
port: u16,
|
|
timeout: Duration,
|
|
) -> io::Result<Vec<SocketAddr>> {
|
|
if timeout.is_zero() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"DNS lookup timeout must be non-zero",
|
|
));
|
|
}
|
|
if let Some(ip) = parse_ip_literal_host(host) {
|
|
return Ok(vec![SocketAddr::new(ip, port)]);
|
|
}
|
|
|
|
let mut resolved = tokio::time::timeout(timeout, tokio::net::lookup_host((host, port)))
|
|
.await
|
|
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "DNS lookup timed out"))??;
|
|
|
|
collect_resolved_addresses_with_limit(&mut resolved)
|
|
}
|
|
|
|
fn collect_resolved_addresses_with_limit(
|
|
resolved: &mut impl Iterator<Item = SocketAddr>,
|
|
) -> io::Result<Vec<SocketAddr>> {
|
|
let mut addresses = Vec::with_capacity(MAX_DNS_RESOLVED_ADDRESSES.min(8));
|
|
for address in resolved.by_ref() {
|
|
if addresses.len() >= MAX_DNS_RESOLVED_ADDRESSES {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"DNS lookup returned too many addresses",
|
|
));
|
|
}
|
|
addresses.push(address);
|
|
}
|
|
Ok(addresses)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::net::SocketAddr;
|
|
|
|
use super::{
|
|
collect_resolved_addresses_with_limit, lookup_host_with_limits, parse_ip_literal_host,
|
|
DEFAULT_DNS_LOOKUP_TIMEOUT, MAX_DNS_RESOLVED_ADDRESSES,
|
|
};
|
|
|
|
#[test]
|
|
fn ip_literal_parser_does_not_turn_bracketed_names_into_hosts() {
|
|
for host in [
|
|
"localhost",
|
|
"[localhost]",
|
|
"[127.0.0.1]",
|
|
"[::1",
|
|
"::1]",
|
|
"[[::1]]",
|
|
] {
|
|
assert_eq!(parse_ip_literal_host(host), None, "{host}");
|
|
}
|
|
for (host, expected) in [
|
|
("198.18.78.41", "198.18.78.41"),
|
|
("::1", "::1"),
|
|
("[::1]", "::1"),
|
|
("[::ffff:127.0.0.1]", "::ffff:127.0.0.1"),
|
|
] {
|
|
assert_eq!(parse_ip_literal_host(host), Some(expected.parse().unwrap()));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_zero_dns_timeout_before_resolving() {
|
|
let error = lookup_host_with_limits("localhost", 80, std::time::Duration::ZERO)
|
|
.await
|
|
.expect_err("zero timeout must be rejected");
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolves_bracketed_ipv6_without_a_hostname_lookup() {
|
|
for host in ["[::1]", "[2606:4700:4700::1111]", "[fd00::1]"] {
|
|
let addresses = lookup_host_with_limits(host, 8443, DEFAULT_DNS_LOOKUP_TIMEOUT)
|
|
.await
|
|
.expect("URL-form IPv6 literals must not be sent to DNS");
|
|
assert_eq!(addresses, vec![format!("{host}:8443").parse().unwrap()]);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolves_within_shared_address_bound() {
|
|
let addresses = lookup_host_with_limits("localhost", 80, DEFAULT_DNS_LOOKUP_TIMEOUT)
|
|
.await
|
|
.expect("localhost should resolve in the test environment");
|
|
assert!(!addresses.is_empty());
|
|
assert!(addresses.len() <= MAX_DNS_RESOLVED_ADDRESSES);
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_every_answer_at_the_shared_bound() {
|
|
let expected = (0..MAX_DNS_RESOLVED_ADDRESSES)
|
|
.map(|index| SocketAddr::from(([198, 18, 0, index as u8], 443)))
|
|
.collect::<Vec<_>>();
|
|
let mut resolved = expected.clone().into_iter();
|
|
assert_eq!(
|
|
collect_resolved_addresses_with_limit(&mut resolved).unwrap(),
|
|
expected
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_an_answer_set_larger_than_the_shared_bound() {
|
|
let mut resolved = (0..=MAX_DNS_RESOLVED_ADDRESSES)
|
|
.map(|index| SocketAddr::from(([192, 0, 2, (index % 254 + 1) as u8], 443)));
|
|
let error = collect_resolved_addresses_with_limit(&mut resolved)
|
|
.expect_err("more than 32 DNS answers must be rejected");
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
|
}
|
|
}
|