use std::net::IpAddr;
use aimux_core::error::AiMuxError;
#[must_use]
pub fn same_origin(url: &str, origin: &str) -> bool {
let (Ok(url), Ok(origin)) = (url::Url::parse(url), url::Url::parse(origin)) else {
return false;
};
url.scheme() == origin.scheme()
&& url.host_str() == origin.host_str()
&& url.port_or_known_default() == origin.port_or_known_default()
}
pub(crate) fn hop_trusted_origin<'a>(
trusted_origin: Option<&'a str>,
current_url: &str,
) -> Option<&'a str> {
trusted_origin.filter(|origin| same_origin(current_url, origin))
}
fn without_query(parsed: &url::Url) -> String {
let mut redacted = parsed.clone();
redacted.set_query(None);
redacted.to_string()
}
fn host_ip_literal(host: &str) -> Option<IpAddr> {
host.trim_start_matches('[')
.trim_end_matches(']')
.parse()
.ok()
}
pub(crate) fn validate_download_url(url: &str) -> Result<url::Url, AiMuxError> {
let parsed = url::Url::parse(url)
.map_err(|error| AiMuxError::InvalidArgument(format!("invalid download URL: {error}")))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(AiMuxError::InvalidArgument(format!(
"download URL must use HTTP or HTTPS: {}",
without_query(&parsed)
)));
}
let host = parsed
.host_str()
.ok_or_else(|| AiMuxError::InvalidArgument("download URL has no host".into()))?;
let normalized_host = host.to_ascii_lowercase();
let normalized_host = normalized_host.trim_end_matches('.');
if normalized_host == "localhost"
|| normalized_host.ends_with(".local")
|| normalized_host.ends_with(".localhost")
{
return Err(AiMuxError::InvalidArgument(format!(
"download URL targets a disallowed hostname: {normalized_host}"
)));
}
if let Some(address) = host_ip_literal(normalized_host)
&& !is_public_download_address(address)
{
return Err(AiMuxError::InvalidArgument(format!(
"download URL targets a non-public address: {address}"
)));
}
Ok(parsed)
}
pub(crate) async fn validate_download_target(
url: &str,
trusted_origin: Option<&str>,
) -> Result<Vec<IpAddr>, AiMuxError> {
if trusted_origin.is_some_and(|origin| same_origin(url, origin)) {
return Ok(Vec::new());
}
let parsed = validate_download_url(url)?;
let host = parsed
.host_str()
.ok_or_else(|| AiMuxError::InvalidArgument("download URL has no host".into()))?;
if let Some(address) = host_ip_literal(host) {
return Ok(vec![address]);
}
let port = parsed
.port_or_known_default()
.ok_or_else(|| AiMuxError::InvalidArgument("download URL has no known port".into()))?;
let addresses: Vec<_> = tokio::net::lookup_host((host, port))
.await
.map_err(|error| {
AiMuxError::InvalidArgument(format!("download URL host could not be resolved: {error}"))
})?
.map(|address| address.ip())
.collect();
validate_resolved_download_addresses(host, addresses)
}
fn validate_resolved_download_addresses(
host: &str,
addresses: Vec<IpAddr>,
) -> Result<Vec<IpAddr>, AiMuxError> {
if addresses.is_empty() {
return Err(AiMuxError::InvalidArgument(format!(
"download URL host did not resolve to an address: {host}"
)));
}
let mut validated = Vec::with_capacity(addresses.len());
for address in addresses {
if !is_public_download_address(address) {
return Err(AiMuxError::InvalidArgument(format!(
"download URL resolves to a non-public address: {address}"
)));
}
if !validated.contains(&address) {
validated.push(address);
}
}
Ok(validated)
}
fn is_public_download_address(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => {
let [a, b, c, _] = address.octets();
!(a == 0
|| a == 10
|| (a == 100 && (64..=127).contains(&b))
|| a == 127
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 0 && matches!(c, 0 | 2))
|| (a == 192 && b == 168)
|| (a == 198 && matches!(b, 18 | 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113)
|| a >= 224)
}
IpAddr::V6(address) => {
let groups = address.segments();
let top_zero = |count: usize| groups[..count].iter().all(|group| *group == 0);
if (top_zero(7) && matches!(groups[7], 0 | 1))
|| (groups[0] & 0xfe00) == 0xfc00
|| (groups[0] & 0xffc0) == 0xfe80
|| (groups[0] & 0xffc0) == 0xfec0
|| (groups[0] & 0xff00) == 0xff00
|| (groups[0] == 0x2001 && groups[1] == 0x0db8)
|| (groups[0] == 0x3fff && (groups[1] & 0xf000) == 0)
{
return false;
}
let embeds_ipv4 = top_zero(6)
|| (top_zero(5) && groups[5] == 0xffff)
|| (top_zero(4) && groups[4] == 0xffff && groups[5] == 0)
|| (groups[0] == 0x0064
&& groups[1] == 0xff9b
&& groups[2..6].iter().all(|group| *group == 0))
|| (groups[0] == 0x0064 && groups[1] == 0xff9b && groups[2] == 1);
if !embeds_ipv4 {
return true;
}
let embedded = std::net::Ipv4Addr::new(
(groups[6] >> 8) as u8,
groups[6] as u8,
(groups[7] >> 8) as u8,
groups[7] as u8,
);
is_public_download_address(IpAddr::V4(embedded))
}
}
}
pub(crate) fn sanitize_download_headers(headers: &mut Vec<(String, String)>) {
const BLOCKED: &[&str] = &[
"connection",
"keep-alive",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
"forwarded",
"proxy-authorization",
"via",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-real-ip",
"metadata",
"metadata-flavor",
"x-aws-ec2-metadata-token",
"x-metadata-token",
"cookie",
"set-cookie",
];
headers.retain(|(name, _)| {
!BLOCKED
.iter()
.any(|blocked| name.eq_ignore_ascii_case(blocked))
});
}
pub(crate) fn retain_user_agent(headers: &mut Vec<(String, String)>) {
let user_agent = headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("user-agent"))
.cloned();
headers.clear();
if let Some(header) = user_agent {
headers.push(header);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn public(address: &str) -> bool {
is_public_download_address(address.parse().unwrap())
}
#[test]
fn address_policy_matches_ai_sdk_ranges() {
for address in [
"0.1.2.3",
"10.0.0.1",
"100.64.0.1",
"100.127.255.255",
"127.255.0.1",
"169.254.169.254",
"172.16.0.1",
"172.31.255.255",
"192.0.0.1",
"192.0.2.1",
"192.168.1.1",
"198.18.0.1",
"198.19.255.255",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"255.255.255.255",
"::",
"::1",
"fc00::1",
"fd12::1",
"fe80::1",
"fec0::1",
"ff02::1",
"2001:db8::1",
"3fff::1",
"3fff:fff::1",
"::7f00:1",
"::ffff:7f00:1",
"::ffff:0:7f00:1",
"64:ff9b::7f00:1",
"64:ff9b::a9fe:a9fe",
"64:ff9b:1::a9fe:a9fe",
] {
assert!(!public(address), "{address} must be blocked");
}
for address in [
"8.8.8.8",
"100.63.0.1",
"100.128.0.1",
"172.15.0.1",
"172.32.0.1",
"192.0.3.1",
"198.51.101.1",
"203.0.114.1",
"2606:4700::1",
"3fff:1000::1",
"::ffff:808:808",
"64:ff9b::808:808",
] {
assert!(public(address), "{address} must be allowed");
}
}
#[test]
fn url_policy_matches_ai_sdk_hostname_and_scheme_rules() {
for url in [
"http://localhost/file",
"http://localhost./file",
"http://myhost.local/file",
"http://myhost.local./file",
"http://app.localhost/file",
"http://app.localhost./file",
"http://2130706433/file",
"http://0x7f000001/file",
"http://0177.0.0.1/file",
"http://[::127.0.0.1]/file",
"http://[::ffff:0:127.0.0.1]/file",
"http://[64:ff9b::169.254.169.254]/file",
"http://[64:ff9b:1::169.254.169.254]/file",
"file:///etc/passwd",
"ftp://example.com/file",
"data:text/plain;base64,aGVsbG8=",
"javascript:alert(1)",
] {
assert!(validate_download_url(url).is_err(), "{url} must be blocked");
}
for url in [
"https://example.com/file",
"https://example.com./file",
"http://8.8.8.8/file",
] {
validate_download_url(url).unwrap_or_else(|error| {
panic!("{url} must be allowed: {error:?}");
});
}
}
#[test]
fn resolved_address_validation_fails_closed_and_preserves_all_answers() {
let empty = validate_resolved_download_addresses("empty.example", Vec::new())
.expect_err("an empty DNS answer must fail closed");
assert!(
matches!(empty, AiMuxError::InvalidArgument(ref message) if message.contains("did not resolve"))
);
let mixed = validate_resolved_download_addresses(
"mixed.example",
vec!["8.8.8.8".parse().unwrap(), "127.0.0.1".parse().unwrap()],
)
.expect_err("one private answer must reject the entire DNS result");
assert!(
matches!(mixed, AiMuxError::InvalidArgument(ref message) if message.contains("non-public"))
);
let all = validate_resolved_download_addresses(
"public.example",
vec![
"8.8.8.8".parse().unwrap(),
"1.1.1.1".parse().unwrap(),
"8.8.8.8".parse().unwrap(),
],
)
.unwrap();
assert_eq!(
all,
vec![
"8.8.8.8".parse::<IpAddr>().unwrap(),
"1.1.1.1".parse::<IpAddr>().unwrap()
]
);
}
}