camel_auth/
http_client.rs1use std::net::IpAddr;
2use std::time::Duration;
3
4use camel_api::SsrfPolicy;
5
6use crate::types::AuthError;
7
8#[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
36pub 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 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 _ => 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
117pub 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 }
140 _ => {
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
221fn is_private_or_loopback_host(host: &str) -> bool {
223 if matches!(host, "localhost" | "localhost.localdomain" | "0.0.0.0") {
226 return true;
227 }
228 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 return camel_api::is_ssrf_blocked_ip(&ip);
239 }
240 false
241}