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