Skip to main content

camel_auth/
http_client.rs

1use std::net::IpAddr;
2use std::time::Duration;
3
4use camel_api::SsrfPolicy;
5
6use crate::types::AuthError;
7
8/// Options for building an SSRF-hardened `reqwest::Client`.
9#[derive(Debug, Clone)]
10pub struct SsrfClientOptions {
11    pub connect_timeout: Duration,
12    pub request_timeout: Duration,
13    pub policy: SsrfPolicy,
14}
15
16impl SsrfClientOptions {
17    pub fn new(policy: SsrfPolicy) -> Self {
18        Self {
19            connect_timeout: Duration::from_secs(10),
20            request_timeout: Duration::from_secs(30),
21            policy,
22        }
23    }
24
25    pub fn with_connect_timeout(mut self, d: Duration) -> Self {
26        self.connect_timeout = d;
27        self
28    }
29
30    pub fn with_request_timeout(mut self, d: Duration) -> Self {
31        self.request_timeout = d;
32        self
33    }
34}
35
36/// Builds an SSRF-pinned [`reqwest::Client`] for the given URI.
37///
38/// 1. **Validates** the URI syntax and scheme against `policy`
39///    ([`validate_uri`]).
40/// 2. **Resolves DNS** (with 5s timeout) and filters out any SSRF-blocked
41///    IPs when policy is `PublicHttpsOnly`, or checks HTTP-over-public-IP
42///    when policy is `AllowInternal`.
43/// 3. **Pins** the validated addresses on the client so `reqwest` never
44///    re-resolves DNS — closing the TOCTOU window between validation
45///    and the first outbound request.
46///
47/// The returned client also has `.no_proxy()`, hardened timeouts, and
48/// redirect disabled.
49pub async fn build_ssrf_pinned_client(
50    uri: &str,
51    label: &str,
52    options: &SsrfClientOptions,
53) -> Result<reqwest::Client, AuthError> {
54    let parsed = validate_uri(uri, label, options.policy)?;
55
56    let host = match parsed.host() {
57        Some(url::Host::Domain(d)) => d.to_string(),
58        Some(url::Host::Ipv4(ip)) => ip.to_string(),
59        Some(url::Host::Ipv6(ip)) => ip.to_string(),
60        None => return Err(AuthError::ConfigError(format!("{label} URI missing host"))),
61    };
62    let port = parsed.port_or_known_default().unwrap_or(443);
63
64    let resolved: Vec<std::net::SocketAddr> = tokio::time::timeout(
65        Duration::from_secs(5),
66        tokio::net::lookup_host((host.as_str(), port)),
67    )
68    .await
69    .map_err(|_| AuthError::ProviderUnavailable(format!("{label} DNS resolution timed out (5s)")))?
70    .map_err(|e| AuthError::ProviderUnavailable(format!("{label} DNS resolution failed: {e}")))?
71    .collect();
72
73    if resolved.is_empty() {
74        return Err(AuthError::ProviderUnavailable(format!(
75            "{label} host '{host}' resolved to zero addresses"
76        )));
77    }
78
79    let validated_addrs: Vec<std::net::SocketAddr> = match options.policy {
80        SsrfPolicy::AllowInternal => {
81            // HTTP over public IP is cleartext to internet — reject.
82            if parsed.scheme() == "http"
83                && resolved
84                    .iter()
85                    .any(|sa| !camel_api::is_ssrf_blocked_ip(&sa.ip()))
86            {
87                return Err(AuthError::ConfigError(format!(
88                    "{label} host '{host}' resolves to a public IP — HTTP not permitted (use HTTPS)"
89                )));
90            }
91            resolved
92        }
93        // PublicHttpsOnly and any future variant fail closed: filter
94        // SSRF-blocked IPs.
95        _ => resolved
96            .into_iter()
97            .filter(|sa| !camel_api::is_ssrf_blocked_ip(&sa.ip()))
98            .collect(),
99    };
100
101    if validated_addrs.is_empty() {
102        return Err(AuthError::ConfigError(format!(
103            "{label} host '{host}' resolves only to blocked IPs (SSRF)"
104        )));
105    }
106
107    reqwest::Client::builder()
108        .resolve_to_addrs(host.as_str(), &validated_addrs)
109        .no_proxy()
110        .redirect(reqwest::redirect::Policy::none())
111        .connect_timeout(options.connect_timeout)
112        .timeout(options.request_timeout)
113        .build()
114        .map_err(|e| AuthError::ConfigError(format!("failed to build {label} HTTP client: {e}")))
115}
116
117/// Validates that `uri` has an http(s) scheme and a host.
118///
119/// Sync syntax-level check only. Resolved-IP classification (HTTP-permitted-
120/// if-all-internal) is enforced in the async `build_ssrf_pinned_client`.
121pub fn validate_uri(uri: &str, label: &str, policy: SsrfPolicy) -> Result<url::Url, AuthError> {
122    let parsed = uri
123        .parse::<url::Url>()
124        .map_err(|e| AuthError::ConfigError(format!("invalid {label} '{uri}': {e}")))?;
125
126    if !matches!(parsed.scheme(), "http" | "https") {
127        return Err(AuthError::ConfigError(format!(
128            "{label} must use http/https (got scheme '{}')",
129            parsed.scheme()
130        )));
131    }
132
133    let host = parsed.host_str().unwrap_or("");
134
135    match policy {
136        SsrfPolicy::AllowInternal => {
137            // Accept http and https; IP-gated decision deferred to async layer.
138            // Only reject obviously non-http(s) schemes (already handled above).
139        }
140        // PublicHttpsOnly and any future variant fail closed: require HTTPS
141        // and reject private/loopback hosts.
142        _ => {
143            if parsed.scheme() != "https" {
144                return Err(AuthError::ConfigError(format!(
145                    "{label} must use HTTPS (got scheme '{}')",
146                    parsed.scheme()
147                )));
148            }
149            if is_private_or_loopback_host(host) {
150                return Err(AuthError::ConfigError(format!(
151                    "{label} host '{host}' is a private or loopback address (SSRF guard)"
152                )));
153            }
154        }
155    }
156
157    Ok(parsed)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn validate_uri_rejects_non_http_scheme() {
166        let err =
167            validate_uri("ftp://example.com", "test", SsrfPolicy::PublicHttpsOnly).unwrap_err();
168        assert!(err.to_string().contains("http/https"));
169    }
170
171    #[test]
172    fn validate_uri_public_https_only_rejects_http() {
173        let err = validate_uri("http://1.1.1.1", "test", SsrfPolicy::PublicHttpsOnly).unwrap_err();
174        assert!(err.to_string().contains("HTTPS"));
175    }
176
177    #[test]
178    fn validate_uri_public_https_only_rejects_localhost() {
179        let err =
180            validate_uri("https://localhost", "test", SsrfPolicy::PublicHttpsOnly).unwrap_err();
181        assert!(err.to_string().contains("private or loopback"));
182    }
183
184    #[test]
185    fn validate_uri_allow_internal_accepts_http() {
186        let result = validate_uri("http://localhost:11434", "test", SsrfPolicy::AllowInternal);
187        assert!(result.is_ok());
188    }
189
190    #[test]
191    fn validate_uri_allow_internal_accepts_https() {
192        let result = validate_uri("https://1.1.1.1", "test", SsrfPolicy::AllowInternal);
193        assert!(result.is_ok());
194    }
195
196    #[test]
197    fn validate_uri_allow_internal_rejects_ftp() {
198        let err = validate_uri("ftp://localhost", "test", SsrfPolicy::AllowInternal).unwrap_err();
199        assert!(err.to_string().contains("http/https"));
200    }
201
202    #[test]
203    fn ssrf_client_options_defaults() {
204        let opts = SsrfClientOptions::new(SsrfPolicy::PublicHttpsOnly);
205        assert_eq!(opts.connect_timeout, Duration::from_secs(10));
206        assert_eq!(opts.request_timeout, Duration::from_secs(30));
207        assert_eq!(opts.policy, SsrfPolicy::PublicHttpsOnly);
208    }
209
210    #[test]
211    fn ssrf_client_options_with_methods() {
212        let opts = SsrfClientOptions::new(SsrfPolicy::AllowInternal)
213            .with_connect_timeout(Duration::from_secs(3))
214            .with_request_timeout(Duration::from_secs(5));
215        assert_eq!(opts.connect_timeout, Duration::from_secs(3));
216        assert_eq!(opts.request_timeout, Duration::from_secs(5));
217        assert_eq!(opts.policy, SsrfPolicy::AllowInternal);
218    }
219}
220
221/// Returns `true` if the host string resolves to a loopback or private IP.
222fn is_private_or_loopback_host(host: &str) -> bool {
223    // Named loopback / unspecified — these don't parse as IP literals,
224    // so we hard-reject the conventional names up front.
225    if matches!(host, "localhost" | "localhost.localdomain" | "0.0.0.0") {
226        return true;
227    }
228    // url::Url::host_str() wraps IPv6 addresses in brackets: "[::1]".
229    // std::net::IpAddr::from_str rejects the bracket form, so strip them first.
230    let ip_str = host
231        .strip_prefix('[')
232        .and_then(|s| s.strip_suffix(']'))
233        .unwrap_or(host);
234    if let Ok(ip) = ip_str.parse::<IpAddr>() {
235        // Delegate to the canonical SSRF block-list so this rule set
236        // stays in lockstep with every other outbound HTTP client
237        // (LLM, Keycloak, …) instead of drifting.
238        return camel_api::is_ssrf_blocked_ip(&ip);
239    }
240    false
241}