Skip to main content

camel_component_http/
config.rs

1use serde::Deserialize;
2
3use camel_component_api::CamelError;
4
5#[derive(Debug, Clone, PartialEq, Deserialize)]
6pub struct HttpConfig {
7    #[serde(default = "default_connect_timeout_ms")]
8    pub connect_timeout_ms: u64,
9    #[serde(default = "default_pool_max_idle_per_host")]
10    pub pool_max_idle_per_host: usize,
11    #[serde(default = "default_pool_idle_timeout_ms")]
12    pub pool_idle_timeout_ms: u64,
13    #[serde(default)]
14    pub follow_redirects: bool,
15    #[serde(default)]
16    pub max_redirects: Option<usize>,
17    #[serde(default = "default_response_timeout_ms")]
18    pub response_timeout_ms: u64,
19    #[serde(default = "default_read_timeout_ms")]
20    pub read_timeout_ms: u64,
21    #[serde(default = "default_max_body_size")]
22    pub max_body_size: usize,
23    #[serde(default = "default_max_response_bytes")]
24    pub max_response_bytes: usize,
25    #[serde(default = "default_max_request_body")]
26    pub max_request_body: usize,
27    #[serde(default)]
28    pub allow_internal: bool,
29    #[serde(default)]
30    pub blocked_hosts: Vec<String>,
31    #[serde(default)]
32    pub ok_status_code_range: Option<String>,
33    #[serde(default)]
34    pub tls: Option<TlsConfig>,
35    #[serde(default)]
36    pub proxy_url: Option<String>,
37}
38
39/// TLS configuration for HTTP/HTTPS client connections.
40#[derive(Clone, PartialEq, Deserialize)]
41pub struct TlsConfig {
42    /// Enables TLS customization for client connections.
43    pub enabled: bool,
44    /// Verifies peer certificates when true. Defaults to true.
45    #[serde(default = "default_verify_peer")]
46    pub verify_peer: bool,
47    /// Optional path to custom CA certificate bundle (PEM or DER).
48    #[serde(default)]
49    pub ca_cert_path: Option<String>,
50    /// Optional path to client certificate for mTLS (PEM).
51    #[serde(default)]
52    pub client_cert_path: Option<String>,
53    /// Optional path to client private key for mTLS (PEM).
54    #[serde(default)]
55    pub client_key_path: Option<String>,
56    /// If true, skips certificate verification (discouraged).
57    #[serde(default)]
58    pub insecure: bool,
59}
60
61fn default_verify_peer() -> bool {
62    true
63}
64
65impl Default for TlsConfig {
66    fn default() -> Self {
67        Self {
68            enabled: false,
69            verify_peer: default_verify_peer(),
70            ca_cert_path: None,
71            client_cert_path: None,
72            client_key_path: None,
73            insecure: false,
74        }
75    }
76}
77
78// Manual Debug redacts the mTLS path fields (client key, client cert, CA
79// bundle). A derived Debug would expose them and leak credential paths
80// (ADR-0051). Mirrors ServerTlsConfig and HttpAuth redaction patterns.
81impl std::fmt::Debug for TlsConfig {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("TlsConfig")
84            .field("enabled", &self.enabled)
85            .field("verify_peer", &self.verify_peer)
86            .field("ca_cert_path", &"[REDACTED]")
87            .field("client_cert_path", &"[REDACTED]")
88            .field("client_key_path", &"[REDACTED]")
89            .field("insecure", &self.insecure)
90            .finish()
91    }
92}
93
94/// Server-side TLS configuration for HTTP consumer endpoints.
95///
96/// When present, the HTTP server binds with TLS via `axum_server::from_tcp_rustls`.
97/// When absent, the server binds plain HTTP via `axum::serve`.
98#[derive(Clone)]
99pub struct ServerTlsConfig {
100    /// Path to the PEM-encoded server certificate chain.
101    pub cert_path: String,
102    /// Path to the PEM-encoded server private key.
103    pub key_path: String,
104}
105
106impl std::fmt::Debug for ServerTlsConfig {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("ServerTlsConfig")
109            .field("cert_path", &"[REDACTED]")
110            .field("key_path", &"[REDACTED]")
111            .finish()
112    }
113}
114
115fn default_connect_timeout_ms() -> u64 {
116    5_000
117}
118
119fn default_pool_max_idle_per_host() -> usize {
120    100
121}
122
123fn default_pool_idle_timeout_ms() -> u64 {
124    90_000
125}
126
127fn default_response_timeout_ms() -> u64 {
128    30_000
129}
130
131fn default_read_timeout_ms() -> u64 {
132    30_000
133}
134
135fn default_max_body_size() -> usize {
136    10_485_760
137}
138
139fn default_max_response_bytes() -> usize {
140    10_485_760
141}
142
143fn default_max_request_body() -> usize {
144    2_097_152
145}
146
147impl Default for HttpConfig {
148    fn default() -> Self {
149        Self {
150            connect_timeout_ms: default_connect_timeout_ms(),
151            pool_max_idle_per_host: default_pool_max_idle_per_host(),
152            pool_idle_timeout_ms: default_pool_idle_timeout_ms(),
153            follow_redirects: false,
154            max_redirects: None,
155            response_timeout_ms: default_response_timeout_ms(),
156            read_timeout_ms: default_read_timeout_ms(),
157            max_body_size: default_max_body_size(),
158            max_response_bytes: default_max_response_bytes(),
159            max_request_body: default_max_request_body(),
160            allow_internal: false,
161            blocked_hosts: Vec::new(),
162            ok_status_code_range: None,
163            tls: None,
164            proxy_url: None,
165        }
166    }
167}
168
169impl HttpConfig {
170    pub fn validate(&self) -> Result<(), CamelError> {
171        if let Some(max_redirects) = self.max_redirects
172            && max_redirects > 20
173        {
174            return Err(CamelError::Config(
175                "max_redirects must be <= 20".to_string(),
176            ));
177        }
178
179        if let Some(range) = &self.ok_status_code_range {
180            parse_ok_status_code_range(range)?;
181        }
182
183        if self.proxy_url.is_some() {
184            return Err(CamelError::Config(
185                "proxy_url is incompatible with SSRF DNS pinning and cannot be used".to_string(),
186            ));
187        }
188
189        Ok(())
190    }
191
192    pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {
193        self.connect_timeout_ms = ms;
194        self
195    }
196    pub fn with_pool_max_idle_per_host(mut self, n: usize) -> Self {
197        self.pool_max_idle_per_host = n;
198        self
199    }
200    pub fn with_pool_idle_timeout_ms(mut self, ms: u64) -> Self {
201        self.pool_idle_timeout_ms = ms;
202        self
203    }
204    pub fn with_follow_redirects(mut self, follow: bool) -> Self {
205        self.follow_redirects = follow;
206        self
207    }
208    pub fn with_max_redirects(mut self, max_redirects: Option<usize>) -> Self {
209        self.max_redirects = max_redirects;
210        self
211    }
212    pub fn with_response_timeout_ms(mut self, ms: u64) -> Self {
213        self.response_timeout_ms = ms;
214        self
215    }
216    pub fn with_read_timeout_ms(mut self, ms: u64) -> Self {
217        self.read_timeout_ms = ms;
218        self
219    }
220    pub fn with_max_body_size(mut self, n: usize) -> Self {
221        self.max_body_size = n;
222        self
223    }
224    pub fn with_max_response_bytes(mut self, n: usize) -> Self {
225        self.max_response_bytes = n;
226        self
227    }
228    pub fn with_max_request_body(mut self, n: usize) -> Self {
229        self.max_request_body = n;
230        self
231    }
232    pub fn with_allow_internal(mut self, allow: bool) -> Self {
233        self.allow_internal = allow;
234        self
235    }
236    pub fn with_blocked_hosts(mut self, hosts: Vec<String>) -> Self {
237        self.blocked_hosts = hosts;
238        self
239    }
240    pub fn with_ok_status_code_range(mut self, range: Option<String>) -> Self {
241        self.ok_status_code_range = range;
242        self
243    }
244    pub fn with_tls(mut self, tls: Option<TlsConfig>) -> Self {
245        self.tls = tls;
246        self
247    }
248}
249
250pub(crate) fn parse_ok_status_code_range(range: &str) -> Result<(u16, u16), CamelError> {
251    let (start_str, end_str) = range.split_once('-').ok_or_else(|| {
252        CamelError::Config("ok_status_code_range must be in NNN-NNN format".to_string())
253    })?;
254
255    if start_str.len() != 3
256        || end_str.len() != 3
257        || !start_str.chars().all(|c| c.is_ascii_digit())
258        || !end_str.chars().all(|c| c.is_ascii_digit())
259    {
260        return Err(CamelError::Config(
261            "ok_status_code_range must be in NNN-NNN format".to_string(),
262        ));
263    }
264
265    let start = start_str
266        .parse::<u16>()
267        .map_err(|_| CamelError::Config("ok_status_code_range start is invalid".to_string()))?;
268    let end = end_str
269        .parse::<u16>()
270        .map_err(|_| CamelError::Config("ok_status_code_range end is invalid".to_string()))?;
271
272    if start > end {
273        return Err(CamelError::Config(
274            "ok_status_code_range start must be <= end".to_string(),
275        ));
276    }
277
278    Ok((start, end))
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_http_config_defaults() {
287        let cfg = HttpConfig::default();
288        assert_eq!(cfg.connect_timeout_ms, 5_000);
289        assert_eq!(cfg.pool_max_idle_per_host, 100);
290        assert_eq!(cfg.pool_idle_timeout_ms, 90_000);
291        assert!(!cfg.follow_redirects);
292        assert_eq!(cfg.max_redirects, None);
293        assert_eq!(cfg.response_timeout_ms, 30_000);
294        assert_eq!(cfg.max_body_size, 10_485_760);
295        assert_eq!(cfg.max_request_body, 2_097_152);
296        assert!(!cfg.allow_internal);
297        assert!(cfg.blocked_hosts.is_empty());
298        assert!(cfg.tls.is_none());
299        assert!(cfg.proxy_url.is_none());
300    }
301
302    #[test]
303    fn test_http_config_builder() {
304        let cfg = HttpConfig::default()
305            .with_connect_timeout_ms(1_000)
306            .with_pool_max_idle_per_host(50)
307            .with_follow_redirects(true)
308            .with_allow_internal(true)
309            .with_blocked_hosts(vec!["evil.com".to_string()]);
310        assert_eq!(cfg.connect_timeout_ms, 1_000);
311        assert_eq!(cfg.pool_max_idle_per_host, 50);
312        assert!(cfg.follow_redirects);
313        assert!(cfg.allow_internal);
314        assert_eq!(cfg.blocked_hosts, vec!["evil.com".to_string()]);
315        assert_eq!(cfg.response_timeout_ms, 30_000);
316    }
317
318    #[test]
319    fn test_rejects_max_redirects_over_limit() {
320        let cfg = HttpConfig {
321            max_redirects: Some(21),
322            ..HttpConfig::default()
323        };
324        assert!(cfg.validate().is_err());
325    }
326
327    #[test]
328    fn test_accepts_valid_max_redirects() {
329        let cfg = HttpConfig {
330            max_redirects: Some(10),
331            ..HttpConfig::default()
332        };
333        assert!(cfg.validate().is_ok());
334    }
335
336    #[test]
337    fn test_rejects_malformed_status_range() {
338        let cfg = HttpConfig {
339            ok_status_code_range: Some("abc-xyz".into()),
340            ..HttpConfig::default()
341        };
342        assert!(cfg.validate().is_err());
343    }
344
345    #[test]
346    fn test_accepts_valid_status_range() {
347        let cfg = HttpConfig {
348            ok_status_code_range: Some("200-299".into()),
349            ..HttpConfig::default()
350        };
351        assert!(cfg.validate().is_ok());
352    }
353
354    #[test]
355    fn test_rejects_proxy_url_with_invalid_url() {
356        // The SSRF rejection fires regardless of URL well-formedness: a
357        // garbage value and a well-formed value both fail the same way.
358        let cfg = HttpConfig {
359            proxy_url: Some("::not-a-proxy::".into()),
360            ..HttpConfig::default()
361        };
362        let err = cfg.validate().expect_err("proxy_url must be rejected");
363        assert!(
364            err.to_string()
365                .contains("incompatible with SSRF DNS pinning"),
366            "expected SSRF rejection message, got: {err}"
367        );
368    }
369
370    #[test]
371    fn test_rejects_proxy_url_with_valid_url() {
372        let cfg = HttpConfig {
373            proxy_url: Some("http://proxy:8080".into()),
374            ..HttpConfig::default()
375        };
376        let err = cfg
377            .validate()
378            .expect_err("valid proxy_url must also be rejected");
379        assert!(
380            err.to_string()
381                .contains("incompatible with SSRF DNS pinning"),
382            "expected SSRF rejection message, got: {err}"
383        );
384    }
385
386    #[test]
387    fn test_proxy_url_toml_deserialize_then_reject() {
388        // The field is retained for serde backward-compat so existing TOML
389        // configs still parse; validate() surfaces the SSRF incompatibility
390        // instead of letting the value silently slip through.
391        let toml_src = r#"
392            connect_timeout_ms = 5000
393            proxy_url = "http://proxy:8080"
394        "#;
395        let cfg: HttpConfig = toml::from_str(toml_src).expect("toml must deserialize");
396        assert_eq!(cfg.proxy_url.as_deref(), Some("http://proxy:8080"));
397
398        let err = cfg
399            .validate()
400            .expect_err("validate must reject deserialized proxy_url");
401        assert!(
402            err.to_string()
403                .contains("incompatible with SSRF DNS pinning"),
404            "expected SSRF rejection message, got: {err}"
405        );
406    }
407
408    #[test]
409    fn server_tls_config_debug_redacts_paths() {
410        let cfg = super::ServerTlsConfig {
411            cert_path: "/secret/cert.pem".to_string(),
412            key_path: "/secret/key.pem".to_string(),
413        };
414        let debug = format!("{:?}", cfg);
415        assert!(
416            !debug.contains("/secret"),
417            "paths must be redacted: {debug}"
418        );
419        assert!(debug.contains("REDACTED"), "must show REDACTED: {debug}");
420    }
421
422    #[test]
423    fn tls_config_debug_redacts_paths() {
424        let cfg = super::TlsConfig {
425            enabled: true,
426            verify_peer: true,
427            ca_cert_path: Some("/secret/ca.pem".to_string()),
428            client_cert_path: Some("/secret/cert.pem".to_string()),
429            client_key_path: Some("/secret/key.pem".to_string()),
430            insecure: false,
431        };
432        let debug = format!("{:?}", cfg);
433        assert!(
434            !debug.contains("/secret"),
435            "sensitive paths must be redacted: {debug}"
436        );
437        assert!(debug.contains("REDACTED"), "must show REDACTED: {debug}");
438        assert!(
439            debug.contains("enabled: true"),
440            "non-sensitive fields must stay visible: {debug}"
441        );
442    }
443}