Skip to main content

microsandbox_network/secrets/
config.rs

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