Skip to main content

microsandbox_network/secrets/
config.rs

1//! Secret injection configuration types.
2
3use serde::{Deserialize, Serialize};
4
5//--------------------------------------------------------------------------------------------------
6// Constants
7//--------------------------------------------------------------------------------------------------
8
9/// Maximum supported secret placeholder length in bytes.
10pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
11
12//--------------------------------------------------------------------------------------------------
13// Types
14//--------------------------------------------------------------------------------------------------
15
16/// Configuration for secret injection in a sandbox.
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct SecretsConfig {
19    /// List of secrets to inject.
20    #[serde(default)]
21    pub secrets: Vec<SecretEntry>,
22
23    /// Action on secret violation (placeholder leaked to disallowed host).
24    #[serde(default)]
25    pub on_violation: ViolationAction,
26}
27
28/// A single secret entry (serializable form passed to the network engine).
29#[derive(Clone, Serialize, Deserialize)]
30pub struct SecretEntry {
31    /// Environment variable name exposed to the sandbox (holds the placeholder).
32    ///
33    /// Must be non-empty and must not contain `=` or NUL. microsandbox does
34    /// not require shell-identifier syntax because Linux environment entries
35    /// only require a `NAME=value` shape.
36    pub env_var: String,
37
38    /// The actual secret value (never enters the sandbox).
39    pub value: String,
40
41    /// Placeholder string the sandbox sees instead of the real value.
42    ///
43    /// Must be non-empty, no longer than 1024 bytes, and must not contain
44    /// NUL, CR, or LF.
45    pub placeholder: String,
46
47    /// Hosts allowed to receive this secret.
48    #[serde(default)]
49    pub allowed_hosts: Vec<HostPattern>,
50
51    /// Where the secret can be injected.
52    #[serde(default)]
53    pub injection: SecretInjection,
54
55    /// Action on secret violation for this secret.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub on_violation: Option<ViolationAction>,
58
59    /// Require verified TLS identity before substituting (default: true).
60    /// When true, secret is only substituted if the connection uses TLS
61    /// interception (not bypass) and the SNI matches an allowed host.
62    #[serde(default = "default_true")]
63    pub require_tls_identity: bool,
64}
65
66/// Host pattern for secret allowlist.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum HostPattern {
70    /// Exact hostname match.
71    #[serde(alias = "Exact")]
72    Exact(String),
73    /// Wildcard match (e.g., `*.openai.com`).
74    #[serde(alias = "Wildcard")]
75    Wildcard(String),
76    /// Any host (dangerous — secret can be exfiltrated).
77    #[serde(alias = "Any")]
78    Any,
79}
80
81/// Invalid secret configuration.
82#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
83pub enum SecretConfigError {
84    /// The environment variable name is empty.
85    #[error("secret #{secret_index}: env_var must not be empty")]
86    EmptyEnvVar {
87        /// Index of the invalid secret entry.
88        secret_index: usize,
89    },
90
91    /// The environment variable name contains `=`.
92    #[error("secret #{secret_index}: env_var must not contain `=`")]
93    EnvVarContainsEquals {
94        /// Index of the invalid secret entry.
95        secret_index: usize,
96    },
97
98    /// The environment variable name contains NUL.
99    #[error("secret #{secret_index}: env_var must not contain NUL")]
100    EnvVarContainsNul {
101        /// Index of the invalid secret entry.
102        secret_index: usize,
103    },
104
105    /// No allowed hosts were configured for a secret.
106    #[error("secret #{secret_index}: at least one allowed host is required")]
107    MissingAllowedHosts {
108        /// Index of the invalid secret entry.
109        secret_index: usize,
110    },
111
112    /// The placeholder is empty.
113    #[error("secret #{secret_index}: placeholder must not be empty")]
114    EmptyPlaceholder {
115        /// Index of the invalid secret entry.
116        secret_index: usize,
117    },
118
119    /// The placeholder exceeds the supported byte length.
120    #[error(
121        "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
122    )]
123    PlaceholderTooLong {
124        /// Index of the invalid secret entry.
125        secret_index: usize,
126        /// Actual placeholder length in bytes.
127        actual_bytes: usize,
128        /// Maximum supported placeholder length in bytes.
129        max_bytes: usize,
130    },
131
132    /// The placeholder contains NUL.
133    #[error("secret #{secret_index}: placeholder must not contain NUL")]
134    PlaceholderContainsNul {
135        /// Index of the invalid secret entry.
136        secret_index: usize,
137    },
138
139    /// The placeholder contains a line break.
140    #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
141    PlaceholderContainsLineBreak {
142        /// Index of the invalid secret entry.
143        secret_index: usize,
144    },
145}
146
147/// Where in the HTTP request the secret can be injected.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SecretInjection {
150    /// Substitute in HTTP headers (default: true).
151    #[serde(default = "default_true")]
152    pub headers: bool,
153
154    /// Substitute in HTTP Basic Auth (default: true).
155    #[serde(default = "default_true")]
156    pub basic_auth: bool,
157
158    /// Substitute in URL query parameters (default: false).
159    #[serde(default)]
160    pub query_params: bool,
161
162    /// Substitute in request body (default: false).
163    ///
164    /// Fixed-length HTTP/1 bodies up to 16 MiB update `Content-Length`;
165    /// larger fixed-length bodies are blocked. Chunked HTTP/1 bodies are
166    /// decoded and re-encoded with fresh chunk sizes. Encoded bodies pass
167    /// through unchanged. HTTP/2 DATA-frame body substitution is not
168    /// supported; matching body placeholders are blocked.
169    #[serde(default)]
170    pub body: bool,
171}
172
173/// Action when a secret placeholder is detected going to a disallowed host.
174#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "kebab-case")]
176pub enum ViolationAction {
177    /// Block the request silently.
178    #[serde(alias = "Block")]
179    Block,
180    /// Block and log (default).
181    #[default]
182    #[serde(alias = "BlockAndLog", alias = "block_and_log")]
183    BlockAndLog,
184    /// Block and terminate the sandbox.
185    #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
186    BlockAndTerminate,
187    /// Forward the request with the placeholder unchanged for matching hosts.
188    #[serde(alias = "Passthrough")]
189    Passthrough(Vec<HostPattern>),
190}
191
192//--------------------------------------------------------------------------------------------------
193// Methods
194//--------------------------------------------------------------------------------------------------
195
196impl SecretsConfig {
197    /// Validate all configured secret entries.
198    pub fn validate(&self) -> Result<(), SecretConfigError> {
199        for (index, secret) in self.secrets.iter().enumerate() {
200            secret.validate(index)?;
201        }
202        Ok(())
203    }
204
205    /// Whether any secret can be substituted over plain HTTP.
206    ///
207    /// True only when at least one secret has opted out of TLS identity
208    /// (`require_tls_identity == false`) and has an enabled injection scope.
209    /// Used to decide whether the plain-HTTP header peek is worth its latency.
210    pub(crate) fn has_plain_http_candidates(&self) -> bool {
211        self.secrets.iter().any(|secret| {
212            !secret.require_tls_identity
213                && (secret.injection.headers
214                    || secret.injection.basic_auth
215                    || secret.injection.query_params
216                    || secret.injection.body)
217        })
218    }
219
220    /// Whether any secret restricts itself to specific hosts (a non-`Any` host
221    /// pattern). Such a secret's plain-HTTP eligibility — substitute, forward
222    /// the placeholder unchanged, or block as a violation — depends on the
223    /// request `Host`, so the peek must read the full header block before the
224    /// handler is built, even for secrets that will never be substituted.
225    pub(crate) fn has_host_scoped_secrets(&self) -> bool {
226        self.secrets
227            .iter()
228            .any(|secret| secret.allowed_hosts.iter().any(|h| *h != HostPattern::Any))
229    }
230}
231
232impl SecretEntry {
233    /// Validate this secret entry.
234    pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
235        validate_env_var(&self.env_var, secret_index)?;
236
237        if self.allowed_hosts.is_empty() {
238            return Err(SecretConfigError::MissingAllowedHosts { secret_index });
239        }
240
241        validate_placeholder(&self.placeholder, secret_index)
242    }
243}
244
245impl std::fmt::Debug for SecretEntry {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        f.debug_struct("SecretEntry")
248            .field("env_var", &self.env_var)
249            .field("value", &"[REDACTED]")
250            .field("placeholder", &self.placeholder)
251            .field("allowed_hosts", &self.allowed_hosts)
252            .field("injection", &self.injection)
253            .field("on_violation", &self.on_violation)
254            .field("require_tls_identity", &self.require_tls_identity)
255            .finish()
256    }
257}
258
259impl HostPattern {
260    /// Check if a hostname matches this pattern.
261    ///
262    /// Uses ASCII case-insensitive comparison to avoid `to_lowercase()`
263    /// allocations (DNS hostnames are ASCII per RFC 4343).
264    pub fn matches(&self, hostname: &str) -> bool {
265        match self {
266            HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
267            HostPattern::Wildcard(pattern) => {
268                if let Some(suffix) = pattern.strip_prefix("*.") {
269                    hostname.eq_ignore_ascii_case(suffix)
270                        || (hostname.len() > suffix.len() + 1
271                            && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
272                            && hostname[hostname.len() - suffix.len()..]
273                                .eq_ignore_ascii_case(suffix))
274                } else {
275                    hostname.eq_ignore_ascii_case(pattern)
276                }
277            }
278            HostPattern::Any => true,
279        }
280    }
281}
282
283//--------------------------------------------------------------------------------------------------
284// Trait Implementations
285//--------------------------------------------------------------------------------------------------
286
287impl Default for SecretInjection {
288    fn default() -> Self {
289        Self {
290            headers: true,
291            basic_auth: true,
292            query_params: false,
293            body: false,
294        }
295    }
296}
297
298//--------------------------------------------------------------------------------------------------
299// Functions
300//--------------------------------------------------------------------------------------------------
301
302fn default_true() -> bool {
303    true
304}
305
306fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
307    if env_var.is_empty() {
308        return Err(SecretConfigError::EmptyEnvVar { secret_index });
309    }
310    if env_var.contains('=') {
311        return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
312    }
313    if env_var.contains('\0') {
314        return Err(SecretConfigError::EnvVarContainsNul { secret_index });
315    }
316    Ok(())
317}
318
319fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
320    if placeholder.is_empty() {
321        return Err(SecretConfigError::EmptyPlaceholder { secret_index });
322    }
323
324    let actual_bytes = placeholder.len();
325    if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
326        return Err(SecretConfigError::PlaceholderTooLong {
327            secret_index,
328            actual_bytes,
329            max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
330        });
331    }
332
333    if placeholder.contains('\0') {
334        return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
335    }
336    if placeholder.contains('\r') || placeholder.contains('\n') {
337        return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
338    }
339
340    Ok(())
341}
342
343//--------------------------------------------------------------------------------------------------
344// Tests
345//--------------------------------------------------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn valid_secret() -> SecretEntry {
352        SecretEntry {
353            env_var: "API_KEY".into(),
354            value: "secret".into(),
355            placeholder: "$MSB_API_KEY".into(),
356            allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
357            injection: SecretInjection::default(),
358            on_violation: None,
359            require_tls_identity: true,
360        }
361    }
362
363    #[test]
364    fn exact_host_match() {
365        let p = HostPattern::Exact("api.openai.com".into());
366        assert!(p.matches("api.openai.com"));
367        assert!(p.matches("API.OpenAI.com"));
368        assert!(!p.matches("evil.com"));
369    }
370
371    #[test]
372    fn wildcard_host_match() {
373        let p = HostPattern::Wildcard("*.openai.com".into());
374        assert!(p.matches("api.openai.com"));
375        assert!(p.matches("openai.com"));
376        assert!(!p.matches("evil.com"));
377    }
378
379    #[test]
380    fn any_host_match() {
381        let p = HostPattern::Any;
382        assert!(p.matches("anything.com"));
383    }
384
385    #[test]
386    fn default_injection_scopes() {
387        let inj = SecretInjection::default();
388        assert!(inj.headers);
389        assert!(inj.basic_auth);
390        assert!(!inj.query_params);
391        assert!(!inj.body);
392    }
393
394    #[test]
395    fn default_require_tls_identity() {
396        let entry = SecretEntry {
397            env_var: "K".into(),
398            value: "v".into(),
399            placeholder: "$K".into(),
400            allowed_hosts: vec![],
401            injection: SecretInjection::default(),
402            on_violation: None,
403            require_tls_identity: true,
404        };
405        assert!(entry.require_tls_identity);
406    }
407
408    #[test]
409    fn secret_validation_accepts_linux_environment_name_shape() {
410        let mut entry = valid_secret();
411        entry.env_var = "1TOKEN.with-dashes".into();
412
413        assert!(entry.validate(0).is_ok());
414    }
415
416    #[test]
417    fn secret_validation_rejects_invalid_env_var_names() {
418        let cases = [
419            ("", SecretConfigError::EmptyEnvVar { secret_index: 0 }),
420            (
421                "API=KEY",
422                SecretConfigError::EnvVarContainsEquals { secret_index: 0 },
423            ),
424            (
425                "API\0KEY",
426                SecretConfigError::EnvVarContainsNul { secret_index: 0 },
427            ),
428        ];
429
430        for (env_var, expected) in cases {
431            let mut entry = valid_secret();
432            entry.env_var = env_var.into();
433            assert_eq!(entry.validate(0), Err(expected));
434        }
435    }
436
437    #[test]
438    fn secret_validation_rejects_missing_allowed_hosts() {
439        let mut entry = valid_secret();
440        entry.allowed_hosts.clear();
441
442        assert_eq!(
443            entry.validate(0),
444            Err(SecretConfigError::MissingAllowedHosts { secret_index: 0 })
445        );
446    }
447
448    #[test]
449    fn secret_validation_rejects_invalid_placeholders() {
450        let too_long = "x".repeat(MAX_SECRET_PLACEHOLDER_BYTES + 1);
451        let cases = [
452            ("", SecretConfigError::EmptyPlaceholder { secret_index: 0 }),
453            (
454                too_long.as_str(),
455                SecretConfigError::PlaceholderTooLong {
456                    secret_index: 0,
457                    actual_bytes: MAX_SECRET_PLACEHOLDER_BYTES + 1,
458                    max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
459                },
460            ),
461            (
462                "abc\0def",
463                SecretConfigError::PlaceholderContainsNul { secret_index: 0 },
464            ),
465            (
466                "abc\rdef",
467                SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
468            ),
469            (
470                "abc\ndef",
471                SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
472            ),
473        ];
474
475        for (placeholder, expected) in cases {
476            let mut entry = valid_secret();
477            entry.placeholder = placeholder.into();
478            assert_eq!(entry.validate(0), Err(expected));
479        }
480    }
481
482    #[test]
483    fn violation_action_serializes_with_sdk_casing() {
484        let action = ViolationAction::Passthrough(vec![
485            HostPattern::Exact("api.anthropic.com".into()),
486            HostPattern::Wildcard("*.anthropic.com".into()),
487            HostPattern::Any,
488        ]);
489
490        assert_eq!(
491            serde_json::to_string(&action).unwrap(),
492            r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
493        );
494        assert_eq!(
495            serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
496            r#""block-and-log""#
497        );
498        assert_eq!(
499            serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
500            r#""block-and-terminate""#
501        );
502    }
503
504    #[test]
505    fn violation_action_accepts_legacy_pascal_case() {
506        let action: ViolationAction =
507            serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();
508
509        assert_eq!(
510            action,
511            ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
512        );
513        assert_eq!(
514            serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
515            ViolationAction::BlockAndTerminate
516        );
517    }
518}