camel_auth/
http_client.rs1use std::net::IpAddr;
2use std::time::Duration;
3
4use crate::types::AuthError;
5
6pub async fn build_ssrf_pinned_client(
17 uri: &str,
18 label: &str,
19 connect_timeout: Duration,
20 request_timeout: Duration,
21) -> Result<reqwest::Client, AuthError> {
22 let parsed = validate_https_public_uri(uri, label)?;
23
24 let host = match parsed.host() {
26 Some(url::Host::Domain(d)) => d.to_string(),
27 Some(url::Host::Ipv4(ip)) => ip.to_string(),
28 Some(url::Host::Ipv6(ip)) => ip.to_string(),
29 None => return Err(AuthError::ConfigError(format!("{label} URI missing host"))),
30 };
31 let port = parsed.port_or_known_default().unwrap_or(443);
32
33 let validated_addrs: Vec<std::net::SocketAddr> = tokio::time::timeout(
35 Duration::from_secs(5),
36 tokio::net::lookup_host((host.as_str(), port)),
37 )
38 .await
39 .map_err(|_| AuthError::ProviderUnavailable(format!("{label} DNS resolution timed out (5s)")))?
40 .map_err(|e| AuthError::ProviderUnavailable(format!("{label} DNS resolution failed: {e}")))?
41 .filter(|sa| !camel_api::is_ssrf_blocked_ip(&sa.ip()))
42 .collect();
43
44 if validated_addrs.is_empty() {
45 return Err(AuthError::ConfigError(format!(
46 "{label} host '{host}' resolves only to blocked IPs (SSRF)"
47 )));
48 }
49
50 reqwest::Client::builder()
52 .resolve_to_addrs(host.as_str(), &validated_addrs)
53 .redirect(reqwest::redirect::Policy::none())
54 .connect_timeout(connect_timeout)
55 .timeout(request_timeout)
56 .build()
57 .map_err(|e| AuthError::ConfigError(format!("failed to build {label} HTTP client: {e}")))
58}
59
60pub fn validate_https_public_uri(uri: &str, label: &str) -> Result<url::Url, AuthError> {
74 let parsed = uri
75 .parse::<url::Url>()
76 .map_err(|e| AuthError::ConfigError(format!("invalid {label} '{uri}': {e}")))?;
77
78 if parsed.scheme() != "https" {
79 return Err(AuthError::ConfigError(format!(
80 "{label} must use HTTPS (got scheme '{}')",
81 parsed.scheme()
82 )));
83 }
84
85 let host = parsed.host_str().unwrap_or("");
86 if is_private_or_loopback_host(host) {
87 return Err(AuthError::ConfigError(format!(
88 "{label} host '{host}' is a private or loopback address (SSRF guard)"
89 )));
90 }
91
92 Ok(parsed)
93}
94
95fn is_private_or_loopback_host(host: &str) -> bool {
97 if matches!(host, "localhost" | "localhost.localdomain" | "0.0.0.0") {
100 return true;
101 }
102 let ip_str = host
105 .strip_prefix('[')
106 .and_then(|s| s.strip_suffix(']'))
107 .unwrap_or(host);
108 if let Ok(ip) = ip_str.parse::<IpAddr>() {
109 return camel_api::is_ssrf_blocked_ip(&ip);
113 }
114 false
115}