Skip to main content

camel_auth/
http_client.rs

1use std::net::IpAddr;
2use std::time::Duration;
3
4use crate::types::AuthError;
5
6/// Builds an SSRF-pinned [`reqwest::Client`] for the given URI.
7///
8/// 1. **Validates** the URI is a public HTTPS endpoint
9///    ([`validate_https_public_uri`]).
10/// 2. **Resolves DNS** (with 5s timeout) and filters out any SSRF-blocked IPs.
11/// 3. **Pins** the validated addresses on the client so `reqwest` never
12///    re-resolves DNS — closing the TOCTOU window between validation
13///    and the first outbound request.
14///
15/// The returned client also has hardened timeouts and redirect disabled.
16pub 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    // Use url::Host enum for proper IPv6 bracket handling.
25    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    // Resolve with timeout, filtering out SSRF-blocked IPs.
34    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    // Pin validated IPs so reqwest never re-resolves DNS.
51    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
60/// Validates that `uri` is a public HTTPS endpoint safe for outbound requests.
61///
62/// Rules:
63/// - Scheme must be `https`
64/// - Host must not be a loopback, private, link-local, CGN, benchmark,
65///   reserved, or otherwise SSRF-blocked address (delegated to the
66///   canonical [`camel_api::is_ssrf_blocked_ip`] helper).
67/// - A small set of well-known loopback hostnames (`localhost`,
68///   `localhost.localdomain`, `0.0.0.0`) is rejected even though they
69///   don't parse as IP literals — DNS for those names conventionally
70///   resolves to a blocked address on every sane system.
71///
72/// Returns the parsed [`url::Url`] on success so callers can reuse it.
73pub 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
95/// Returns `true` if the host string resolves to a loopback or private IP.
96fn is_private_or_loopback_host(host: &str) -> bool {
97    // Named loopback / unspecified — these don't parse as IP literals,
98    // so we hard-reject the conventional names up front.
99    if matches!(host, "localhost" | "localhost.localdomain" | "0.0.0.0") {
100        return true;
101    }
102    // url::Url::host_str() wraps IPv6 addresses in brackets: "[::1]".
103    // std::net::IpAddr::from_str rejects the bracket form, so strip them first.
104    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        // Delegate to the canonical SSRF block-list so this rule set
110        // stays in lockstep with every other outbound HTTP client
111        // (LLM, Keycloak, …) instead of drifting.
112        return camel_api::is_ssrf_blocked_ip(&ip);
113    }
114    false
115}