use std::net::IpAddr;
use fraiseql_error::{FraiseQLError, Result};
#[derive(Debug, Clone)]
pub struct HttpClientConfig {
pub allowed_domains: Vec<String>,
pub max_response_bytes: usize,
pub connect_timeout_ms: u64,
pub read_timeout_ms: u64,
}
impl Default for HttpClientConfig {
fn default() -> Self {
Self {
allowed_domains: vec!["*".to_string()],
max_response_bytes: 10 * 1024 * 1024, connect_timeout_ms: 5000,
read_timeout_ms: 30000,
}
}
}
pub fn validate_outbound_url(url: &str, config: &HttpClientConfig) -> Result<()> {
let parsed_url = reqwest::Url::parse(url).map_err(|e| FraiseQLError::Validation {
message: format!("invalid URL: {}", e),
path: None,
})?;
let host = parsed_url.host_str().ok_or_else(|| FraiseQLError::Validation {
message: "URL has no host".to_string(),
path: None,
})?;
if !is_domain_allowed(host, &config.allowed_domains) {
return Err(FraiseQLError::Authorization {
message: format!("domain '{}' not in allowlist", host),
action: Some("http_request".to_string()),
resource: Some(host.to_string()),
});
}
if let Ok(ip) = parse_ip_from_host(host) {
validate_ip(&ip)?;
}
Ok(())
}
fn is_domain_allowed(host: &str, allowlist: &[String]) -> bool {
for pattern in allowlist {
if pattern == "*" {
return true;
}
let host_for_comparison = if let Some(colon_pos) = host.rfind(':') {
if !host.starts_with('[') {
&host[..colon_pos]
} else {
host
}
} else {
host
};
if host_for_comparison == pattern || host == pattern {
return true;
}
if let Some(domain) = pattern.strip_prefix("*.") {
if host_for_comparison.ends_with(&format!(".{}", domain)) {
return true;
}
}
}
false
}
fn parse_ip_from_host(host: &str) -> Result<IpAddr> {
let clean_host = if host.starts_with('[') && host.ends_with(']') {
&host[1..host.len() - 1]
} else {
host
};
clean_host.parse::<IpAddr>().map_err(|e| FraiseQLError::Validation {
message: format!("failed to parse IP address: {}", e),
path: None,
})
}
fn validate_ip(ip: &IpAddr) -> Result<()> {
match ip {
IpAddr::V4(v4) => {
if v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| is_ipv4_reserved(*v4)
{
return Err(FraiseQLError::Authorization {
message: format!("private/reserved IP address not allowed: {}", v4),
action: Some("http_request".to_string()),
resource: Some(v4.to_string()),
});
}
Ok(())
},
IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local() {
return Err(FraiseQLError::Authorization {
message: format!("private IPv6 address not allowed: {}", v6),
action: Some("http_request".to_string()),
resource: Some(v6.to_string()),
});
}
Ok(())
},
}
}
const fn is_ipv4_reserved(ip: std::net::Ipv4Addr) -> bool {
let octets = ip.octets();
matches!(octets[0], 0 | 100..=127 | 240..=255)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests;