use fraiseql_error::{FraiseQLError, Result};
use fraiseql_guard::net::{BlockedReason, resolve::Refusal};
#[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![],
max_response_bytes: 10 * 1024 * 1024, connect_timeout_ms: 5000,
read_timeout_ms: 30000,
}
}
}
pub async 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()),
});
}
let port = parsed_url.port_or_known_default().unwrap_or(443);
let refused = |message: String, resource: String| FraiseQLError::Authorization {
message,
action: Some("http_request".to_string()),
resource: Some(resource),
};
match fraiseql_guard::net::resolve::resolve_and_check(host, port).await {
Ok(_) => Ok(()),
Err(Refusal::Host(BlockedReason::ReservedAddress)) => Err(refused(
format!("private/reserved IP address not allowed: {host}"),
host.to_string(),
)),
Err(Refusal::Host(reason)) => {
Err(refused(format!("host '{host}' not allowed: {reason}"), host.to_string()))
},
Err(Refusal::BlockedAddress(ip)) => Err(refused(
format!("private/reserved IP address not allowed: {ip}"),
ip.to_string(),
)),
Err(other @ (Refusal::ResolutionFailed(_) | Refusal::NoAddresses)) => {
Err(FraiseQLError::Validation {
message: format!("host '{host}': {other}"),
path: None,
})
},
}
}
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
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests;