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::PublicHttpsOnly => resolved
81 .into_iter()
82 .filter(|sa| !camel_api::is_ssrf_blocked_ip(&sa.ip()))
83 .collect(),
84 SsrfPolicy::AllowInternal => {
85 if parsed.scheme() == "http"
87 && resolved
88 .iter()
89 .any(|sa| !camel_api::is_ssrf_blocked_ip(&sa.ip()))
90 {
91 return Err(AuthError::ConfigError(format!(
92 "{label} host '{host}' resolves to a public IP — HTTP not permitted (use HTTPS)"
93 )));
94 }
95 resolved
96 }
97 };
98
99 if validated_addrs.is_empty() {
100 return Err(AuthError::ConfigError(format!(
101 "{label} host '{host}' resolves only to blocked IPs (SSRF)"
102 )));
103 }
104
105 reqwest::Client::builder()
106 .resolve_to_addrs(host.as_str(), &validated_addrs)
107 .no_proxy()
108 .redirect(reqwest::redirect::Policy::none())
109 .connect_timeout(options.connect_timeout)
110 .timeout(options.request_timeout)
111 .build()
112 .map_err(|e| AuthError::ConfigError(format!("failed to build {label} HTTP client: {e}")))
113}
114
115pub fn validate_uri(uri: &str, label: &str, policy: SsrfPolicy) -> Result<url::Url, AuthError> {
120 let parsed = uri
121 .parse::<url::Url>()
122 .map_err(|e| AuthError::ConfigError(format!("invalid {label} '{uri}': {e}")))?;
123
124 if !matches!(parsed.scheme(), "http" | "https") {
125 return Err(AuthError::ConfigError(format!(
126 "{label} must use http/https (got scheme '{}')",
127 parsed.scheme()
128 )));
129 }
130
131 let host = parsed.host_str().unwrap_or("");
132
133 match policy {
134 SsrfPolicy::PublicHttpsOnly => {
135 if parsed.scheme() != "https" {
136 return Err(AuthError::ConfigError(format!(
137 "{label} must use HTTPS (got scheme '{}')",
138 parsed.scheme()
139 )));
140 }
141 if is_private_or_loopback_host(host) {
142 return Err(AuthError::ConfigError(format!(
143 "{label} host '{host}' is a private or loopback address (SSRF guard)"
144 )));
145 }
146 }
147 SsrfPolicy::AllowInternal => {
148 }
151 }
152
153 Ok(parsed)
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn validate_uri_rejects_non_http_scheme() {
162 let err =
163 validate_uri("ftp://example.com", "test", SsrfPolicy::PublicHttpsOnly).unwrap_err();
164 assert!(err.to_string().contains("http/https"));
165 }
166
167 #[test]
168 fn validate_uri_public_https_only_rejects_http() {
169 let err = validate_uri("http://1.1.1.1", "test", SsrfPolicy::PublicHttpsOnly).unwrap_err();
170 assert!(err.to_string().contains("HTTPS"));
171 }
172
173 #[test]
174 fn validate_uri_public_https_only_rejects_localhost() {
175 let err =
176 validate_uri("https://localhost", "test", SsrfPolicy::PublicHttpsOnly).unwrap_err();
177 assert!(err.to_string().contains("private or loopback"));
178 }
179
180 #[test]
181 fn validate_uri_allow_internal_accepts_http() {
182 let result = validate_uri("http://localhost:11434", "test", SsrfPolicy::AllowInternal);
183 assert!(result.is_ok());
184 }
185
186 #[test]
187 fn validate_uri_allow_internal_accepts_https() {
188 let result = validate_uri("https://1.1.1.1", "test", SsrfPolicy::AllowInternal);
189 assert!(result.is_ok());
190 }
191
192 #[test]
193 fn validate_uri_allow_internal_rejects_ftp() {
194 let err = validate_uri("ftp://localhost", "test", SsrfPolicy::AllowInternal).unwrap_err();
195 assert!(err.to_string().contains("http/https"));
196 }
197
198 #[test]
199 fn ssrf_client_options_defaults() {
200 let opts = SsrfClientOptions::new(SsrfPolicy::PublicHttpsOnly);
201 assert_eq!(opts.connect_timeout, Duration::from_secs(10));
202 assert_eq!(opts.request_timeout, Duration::from_secs(30));
203 assert_eq!(opts.policy, SsrfPolicy::PublicHttpsOnly);
204 }
205
206 #[test]
207 fn ssrf_client_options_with_methods() {
208 let opts = SsrfClientOptions::new(SsrfPolicy::AllowInternal)
209 .with_connect_timeout(Duration::from_secs(3))
210 .with_request_timeout(Duration::from_secs(5));
211 assert_eq!(opts.connect_timeout, Duration::from_secs(3));
212 assert_eq!(opts.request_timeout, Duration::from_secs(5));
213 assert_eq!(opts.policy, SsrfPolicy::AllowInternal);
214 }
215}
216
217fn is_private_or_loopback_host(host: &str) -> bool {
219 if matches!(host, "localhost" | "localhost.localdomain" | "0.0.0.0") {
222 return true;
223 }
224 let ip_str = host
227 .strip_prefix('[')
228 .and_then(|s| s.strip_suffix(']'))
229 .unwrap_or(host);
230 if let Ok(ip) = ip_str.parse::<IpAddr>() {
231 return camel_api::is_ssrf_blocked_ip(&ip);
235 }
236 false
237}