Skip to main content

relay_core_api/
policy.rs

1use secrecy::{ExposeSecret, SecretString};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub struct RedactionPolicy {
8    #[serde(default = "default_false")]
9    pub enabled: bool,
10    #[serde(default = "default_sensitive_header_names")]
11    pub sensitive_header_names: Vec<String>,
12    #[serde(default = "default_sensitive_query_keys")]
13    pub sensitive_query_keys: Vec<String>,
14    #[serde(default = "default_false")]
15    pub redact_bodies: bool,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
19pub struct RedactionPolicyPatch {
20    #[serde(default)]
21    pub enabled: Option<bool>,
22    #[serde(default)]
23    pub sensitive_header_names: Option<Vec<String>>,
24    #[serde(default)]
25    pub sensitive_query_keys: Option<Vec<String>>,
26    #[serde(default)]
27    pub redact_bodies: Option<bool>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
31pub struct ProxyPolicyPatch {
32    #[serde(default)]
33    pub redaction: Option<RedactionPolicyPatch>,
34    #[serde(default)]
35    pub upstream: Option<UpstreamProxyConfig>,
36}
37
38// ── Upstream Proxy ──────────────────────────────────────
39
40/// Upstream proxy configuration.
41#[derive(Clone, Serialize, Deserialize)]
42pub struct UpstreamProxyConfig {
43    /// Upstream proxy URL, e.g. "http://corp-proxy:8080" or "https://secure-proxy:8443"
44    pub proxy_url: String,
45    /// Optional HTTP Basic authentication
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub auth: Option<UpstreamAuth>,
48    /// Hosts to bypass upstream (CIDR with `cidr:` prefix, IP literals, or glob hostnames)
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub bypass_hosts: Vec<String>,
51    /// When true, unreachable upstream falls back to direct connection (default false = fail-closed)
52    #[serde(default)]
53    pub fail_open: bool,
54}
55
56/// HTTP Basic credentials for upstream proxy authentication.
57#[derive(Clone, Serialize, Deserialize)]
58pub struct UpstreamAuth {
59    pub username: String,
60    #[serde(
61        serialize_with = "serialize_secret",
62        deserialize_with = "deserialize_secret"
63    )]
64    pub password: SecretString,
65}
66
67fn serialize_secret<S: serde::Serializer>(_v: &SecretString, s: S) -> Result<S::Ok, S::Error> {
68    s.serialize_str("***")
69}
70fn deserialize_secret<'de, D: serde::Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
71    let s = String::deserialize(d)?;
72    Ok(SecretString::new(s.into()))
73}
74
75impl fmt::Debug for UpstreamProxyConfig {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.debug_struct("UpstreamProxyConfig")
78            .field("proxy_url", &self.proxy_url)
79            .field("auth", &self.auth.as_ref().map(|_| "***"))
80            .field("bypass_hosts", &self.bypass_hosts)
81            .field("fail_open", &self.fail_open)
82            .finish()
83    }
84}
85
86impl UpstreamAuth {
87    pub fn new(username: String, password: String) -> Self {
88        Self {
89            username,
90            password: SecretString::new(password.into()),
91        }
92    }
93}
94
95impl fmt::Debug for UpstreamAuth {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.debug_struct("UpstreamAuth")
98            .field("username", &self.username)
99            .field("password", &"***")
100            .finish()
101    }
102}
103
104impl PartialEq for UpstreamProxyConfig {
105    fn eq(&self, other: &Self) -> bool {
106        self.proxy_url == other.proxy_url
107            && self.bypass_hosts == other.bypass_hosts
108            && self.fail_open == other.fail_open
109            && match (&self.auth, &other.auth) {
110                (Some(a), Some(b)) => {
111                    a.username == b.username
112                        && a.password.expose_secret() == b.password.expose_secret()
113                }
114                (None, None) => true,
115                _ => false,
116            }
117    }
118}
119
120impl Eq for UpstreamProxyConfig {}
121
122impl Default for RedactionPolicy {
123    fn default() -> Self {
124        Self {
125            enabled: false,
126            sensitive_header_names: default_sensitive_header_names(),
127            sensitive_query_keys: default_sensitive_query_keys(),
128            redact_bodies: false,
129        }
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ProxyPolicy {
135    /// In strict mode, invalid method/status does not silently rewrite to GET/200.
136    #[serde(default = "default_true")]
137    pub strict_http_semantics: bool,
138
139    /// Allow fallback to GET for invalid methods (only if strict_http_semantics is false)
140    #[serde(default = "default_false")]
141    pub allow_fallback_method: bool,
142
143    /// Allow fallback to 200 OK for invalid status (only if strict_http_semantics is false)
144    #[serde(default = "default_false")]
145    pub allow_fallback_status: bool,
146
147    /// Enable automatic retries for idempotent requests
148    #[serde(default = "default_false")]
149    pub enable_retry: bool,
150
151    /// Only retry idempotent methods (GET, HEAD, OPTIONS)
152    #[serde(default = "default_true")]
153    pub retry_idempotent_only: bool,
154
155    /// Maximum number of retries
156    #[serde(default = "default_max_retries")]
157    pub max_retries: u8,
158
159    /// Root directory for local file access (sandbox)
160    pub sandbox_root: Option<PathBuf>,
161
162    /// Maximum allowed size for local file read
163    #[serde(default = "default_max_file_bytes")]
164    pub max_local_file_bytes: usize,
165
166    /// Maximum allowed request/response body size for proxy inspection
167    #[serde(default = "default_max_body_bytes")]
168    pub max_body_size: usize,
169
170    /// P1: Maximum bytes to buffer for rule body inspection.
171    /// When exceeded, body-stage rules are skipped with budget_exceeded tag.
172    /// Default: 1 MB. Set to 0 to disable rule body inspection entirely.
173    #[serde(default = "default_rule_body_inspect_budget")]
174    pub rule_body_inspect_budget: usize,
175
176    /// Request timeout in milliseconds (connect + send request + receive response headers)
177    #[serde(default = "default_request_timeout_ms")]
178    pub request_timeout_ms: u64,
179
180    /// Enable transparent proxy mode
181    #[serde(default = "default_false")]
182    pub transparent_enabled: bool,
183
184    /// Require original destination to be present (strict mode)
185    #[serde(default = "default_true")]
186    pub transparent_require_original_dst: bool,
187
188    /// Allow fallback to Host header when original destination is missing
189    #[serde(default = "default_false")]
190    pub transparent_allow_host_fallback: bool,
191
192    /// Reject connections that would create a loop
193    #[serde(default = "default_true")]
194    pub transparent_reject_loopback_target: bool,
195
196    /// Log level for transparent proxy events
197    #[serde(default = "default_transparent_log_level")]
198    pub transparent_log_level: TransparentLogLevel,
199
200    /// QUIC handling mode
201    #[serde(default = "default_quic_mode")]
202    pub quic_mode: QuicMode,
203
204    /// Optionally emit Clear-Site-Data: "cache" to invalidate client Alt-Svc cache
205    #[serde(default = "default_false")]
206    pub quic_downgrade_clear_cache: bool,
207
208    #[serde(default)]
209    pub redaction: RedactionPolicy,
210
211    /// Upstream (parent) proxy configuration. None = direct connection mode.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub upstream: Option<UpstreamProxyConfig>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217pub enum TransparentLogLevel {
218    Silent, // No logging
219    Info,   // Log connections only
220    Debug,  // Log with destination details
221    Trace,  // Full packet-level logging (expensive)
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub enum QuicMode {
226    /// Force clients to use HTTP/1.1 or HTTP/2
227    Downgrade,
228
229    /// Pass through QUIC traffic without inspection
230    Passthrough,
231
232    /// [EXPERIMENTAL] Full HTTP/3 MITM
233    #[cfg(feature = "quic_mitm_experimental")]
234    ExperimentalMitm,
235}
236
237fn default_quic_mode() -> QuicMode {
238    QuicMode::Downgrade
239}
240
241impl Default for ProxyPolicy {
242    fn default() -> Self {
243        Self {
244            strict_http_semantics: true,
245            allow_fallback_method: false,
246            allow_fallback_status: false,
247            enable_retry: false,
248            retry_idempotent_only: true,
249            max_retries: 3,
250            sandbox_root: None,
251            max_local_file_bytes: 10 * 1024 * 1024, // 10MB
252            max_body_size: 10 * 1024 * 1024,        // 10MB
253            rule_body_inspect_budget: 1024 * 1024,  // 1MB
254            request_timeout_ms: 30_000,             // 30 seconds
255            transparent_enabled: false,
256            transparent_require_original_dst: true,
257            transparent_allow_host_fallback: false,
258            transparent_reject_loopback_target: true,
259            transparent_log_level: TransparentLogLevel::Info,
260            quic_mode: QuicMode::Downgrade,
261            quic_downgrade_clear_cache: false,
262            redaction: RedactionPolicy::default(),
263            upstream: None,
264        }
265    }
266}
267
268impl RedactionPolicy {
269    pub fn apply_patch(&mut self, patch: RedactionPolicyPatch) {
270        if let Some(enabled) = patch.enabled {
271            self.enabled = enabled;
272        }
273        if let Some(names) = patch.sensitive_header_names {
274            self.sensitive_header_names = names;
275        }
276        if let Some(keys) = patch.sensitive_query_keys {
277            self.sensitive_query_keys = keys;
278        }
279        if let Some(redact_bodies) = patch.redact_bodies {
280            self.redact_bodies = redact_bodies;
281        }
282    }
283}
284
285impl ProxyPolicy {
286    pub fn apply_patch(&mut self, patch: ProxyPolicyPatch) {
287        if let Some(redaction_patch) = patch.redaction {
288            self.redaction.apply_patch(redaction_patch);
289        }
290        if let Some(upstream) = patch.upstream {
291            self.upstream = Some(upstream);
292        }
293    }
294}
295
296fn default_true() -> bool {
297    true
298}
299fn default_false() -> bool {
300    false
301}
302fn default_max_retries() -> u8 {
303    3
304}
305fn default_max_file_bytes() -> usize {
306    10 * 1024 * 1024
307}
308fn default_max_body_bytes() -> usize {
309    10 * 1024 * 1024
310}
311fn default_rule_body_inspect_budget() -> usize {
312    1024 * 1024 // 1MB
313}
314fn default_request_timeout_ms() -> u64 {
315    30_000
316}
317fn default_transparent_log_level() -> TransparentLogLevel {
318    TransparentLogLevel::Info
319}
320fn default_sensitive_header_names() -> Vec<String> {
321    vec![
322        "authorization".to_string(),
323        "proxy-authorization".to_string(),
324        "cookie".to_string(),
325        "set-cookie".to_string(),
326        "x-api-key".to_string(),
327        "x-auth-token".to_string(),
328    ]
329}
330fn default_sensitive_query_keys() -> Vec<String> {
331    vec![
332        "token".to_string(),
333        "access_token".to_string(),
334        "refresh_token".to_string(),
335        "api_key".to_string(),
336        "apikey".to_string(),
337        "password".to_string(),
338        "secret".to_string(),
339    ]
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use secrecy::SecretString;
346
347    #[test]
348    fn upstream_proxy_config_debug_masks_password() {
349        let cfg = UpstreamProxyConfig {
350            proxy_url: "http://proxy:8080".to_string(),
351            auth: Some(UpstreamAuth {
352                username: "user".to_string(),
353                password: SecretString::new("s3cret".to_string().into()),
354            }),
355            bypass_hosts: vec!["*.local".to_string()],
356            fail_open: false,
357        };
358        let dbg = format!("{:?}", cfg);
359        assert!(dbg.contains("http://proxy:8080"));
360        assert!(dbg.contains("***"));
361        assert!(!dbg.contains("s3cret"));
362    }
363
364    #[test]
365    fn upstream_auth_debug_masks_password() {
366        let auth = UpstreamAuth {
367            username: "user".to_string(),
368            password: SecretString::new("s3cret".to_string().into()),
369        };
370        let dbg = format!("{:?}", auth);
371        assert!(dbg.contains("user"));
372        assert!(dbg.contains("***"));
373        assert!(!dbg.contains("s3cret"));
374    }
375
376    #[test]
377    fn proxy_policy_default_has_no_upstream() {
378        let policy = ProxyPolicy::default();
379        assert!(policy.upstream.is_none());
380    }
381
382    #[test]
383    fn proxy_policy_patch_applies_upstream() {
384        let mut policy = ProxyPolicy::default();
385        let upstream = UpstreamProxyConfig {
386            proxy_url: "http://corp:8080".to_string(),
387            auth: None,
388            bypass_hosts: vec![],
389            fail_open: false,
390        };
391        policy.apply_patch(ProxyPolicyPatch {
392            redaction: None,
393            upstream: Some(upstream.clone()),
394        });
395        assert_eq!(policy.upstream, Some(upstream));
396    }
397
398    #[test]
399    fn upstream_proxy_config_serde_masks_password() {
400        let cfg = UpstreamProxyConfig {
401            proxy_url: "https://secure-proxy:8443".to_string(),
402            auth: Some(UpstreamAuth {
403                username: "admin".to_string(),
404                password: SecretString::new("p@ss".to_string().into()),
405            }),
406            bypass_hosts: vec!["cidr:10.0.0.0/8".to_string(), "*.internal".to_string()],
407            fail_open: true,
408        };
409        let json = serde_json::to_string(&cfg).unwrap();
410        // Serialized JSON must NOT contain the real password.
411        assert!(!json.contains("p@ss"));
412        assert!(json.contains("***"));
413        // Non-password fields round-trip correctly.
414        let decoded: UpstreamProxyConfig = serde_json::from_str(&json).unwrap();
415        assert_eq!(decoded.proxy_url, cfg.proxy_url);
416        assert_eq!(decoded.bypass_hosts, cfg.bypass_hosts);
417        assert_eq!(decoded.fail_open, cfg.fail_open);
418    }
419
420    #[test]
421    fn proxy_policy_serialization_hides_upstream_when_none() {
422        let policy = ProxyPolicy::default();
423        let json = serde_json::to_string(&policy).unwrap();
424        assert!(!json.contains("upstream"));
425    }
426
427    #[test]
428    fn upstream_partial_eq_compares_password() {
429        let a = UpstreamProxyConfig {
430            proxy_url: "http://p:8080".to_string(),
431            auth: Some(UpstreamAuth {
432                username: "u".to_string(),
433                password: SecretString::new("a".to_string().into()),
434            }),
435            bypass_hosts: vec![],
436            fail_open: false,
437        };
438        let b = UpstreamProxyConfig {
439            proxy_url: "http://p:8080".to_string(),
440            auth: Some(UpstreamAuth {
441                username: "u".to_string(),
442                password: SecretString::new("b".to_string().into()),
443            }),
444            bypass_hosts: vec![],
445            fail_open: false,
446        };
447        assert_ne!(a, b);
448    }
449}