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
206impl SecretEntry {
207    /// Validate this secret entry.
208    pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
209        validate_env_var(&self.env_var, secret_index)?;
210
211        if self.allowed_hosts.is_empty() {
212            return Err(SecretConfigError::MissingAllowedHosts { secret_index });
213        }
214
215        validate_placeholder(&self.placeholder, secret_index)
216    }
217}
218
219impl std::fmt::Debug for SecretEntry {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        f.debug_struct("SecretEntry")
222            .field("env_var", &self.env_var)
223            .field("value", &"[REDACTED]")
224            .field("placeholder", &self.placeholder)
225            .field("allowed_hosts", &self.allowed_hosts)
226            .field("injection", &self.injection)
227            .field("on_violation", &self.on_violation)
228            .field("require_tls_identity", &self.require_tls_identity)
229            .finish()
230    }
231}
232
233impl HostPattern {
234    /// Check if a hostname matches this pattern.
235    ///
236    /// Uses ASCII case-insensitive comparison to avoid `to_lowercase()`
237    /// allocations (DNS hostnames are ASCII per RFC 4343).
238    pub fn matches(&self, hostname: &str) -> bool {
239        match self {
240            HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
241            HostPattern::Wildcard(pattern) => {
242                if let Some(suffix) = pattern.strip_prefix("*.") {
243                    hostname.eq_ignore_ascii_case(suffix)
244                        || (hostname.len() > suffix.len() + 1
245                            && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
246                            && hostname[hostname.len() - suffix.len()..]
247                                .eq_ignore_ascii_case(suffix))
248                } else {
249                    hostname.eq_ignore_ascii_case(pattern)
250                }
251            }
252            HostPattern::Any => true,
253        }
254    }
255}
256
257//--------------------------------------------------------------------------------------------------
258// Trait Implementations
259//--------------------------------------------------------------------------------------------------
260
261impl Default for SecretInjection {
262    fn default() -> Self {
263        Self {
264            headers: true,
265            basic_auth: true,
266            query_params: false,
267            body: false,
268        }
269    }
270}
271
272//--------------------------------------------------------------------------------------------------
273// Functions
274//--------------------------------------------------------------------------------------------------
275
276fn default_true() -> bool {
277    true
278}
279
280fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
281    if env_var.is_empty() {
282        return Err(SecretConfigError::EmptyEnvVar { secret_index });
283    }
284    if env_var.contains('=') {
285        return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
286    }
287    if env_var.contains('\0') {
288        return Err(SecretConfigError::EnvVarContainsNul { secret_index });
289    }
290    Ok(())
291}
292
293fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
294    if placeholder.is_empty() {
295        return Err(SecretConfigError::EmptyPlaceholder { secret_index });
296    }
297
298    let actual_bytes = placeholder.len();
299    if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
300        return Err(SecretConfigError::PlaceholderTooLong {
301            secret_index,
302            actual_bytes,
303            max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
304        });
305    }
306
307    if placeholder.contains('\0') {
308        return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
309    }
310    if placeholder.contains('\r') || placeholder.contains('\n') {
311        return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
312    }
313
314    Ok(())
315}
316
317//--------------------------------------------------------------------------------------------------
318// Tests
319//--------------------------------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn valid_secret() -> SecretEntry {
326        SecretEntry {
327            env_var: "API_KEY".into(),
328            value: "secret".into(),
329            placeholder: "$MSB_API_KEY".into(),
330            allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
331            injection: SecretInjection::default(),
332            on_violation: None,
333            require_tls_identity: true,
334        }
335    }
336
337    #[test]
338    fn exact_host_match() {
339        let p = HostPattern::Exact("api.openai.com".into());
340        assert!(p.matches("api.openai.com"));
341        assert!(p.matches("API.OpenAI.com"));
342        assert!(!p.matches("evil.com"));
343    }
344
345    #[test]
346    fn wildcard_host_match() {
347        let p = HostPattern::Wildcard("*.openai.com".into());
348        assert!(p.matches("api.openai.com"));
349        assert!(p.matches("openai.com"));
350        assert!(!p.matches("evil.com"));
351    }
352
353    #[test]
354    fn any_host_match() {
355        let p = HostPattern::Any;
356        assert!(p.matches("anything.com"));
357    }
358
359    #[test]
360    fn default_injection_scopes() {
361        let inj = SecretInjection::default();
362        assert!(inj.headers);
363        assert!(inj.basic_auth);
364        assert!(!inj.query_params);
365        assert!(!inj.body);
366    }
367
368    #[test]
369    fn default_require_tls_identity() {
370        let entry = SecretEntry {
371            env_var: "K".into(),
372            value: "v".into(),
373            placeholder: "$K".into(),
374            allowed_hosts: vec![],
375            injection: SecretInjection::default(),
376            on_violation: None,
377            require_tls_identity: true,
378        };
379        assert!(entry.require_tls_identity);
380    }
381
382    #[test]
383    fn secret_validation_accepts_linux_environment_name_shape() {
384        let mut entry = valid_secret();
385        entry.env_var = "1TOKEN.with-dashes".into();
386
387        assert!(entry.validate(0).is_ok());
388    }
389
390    #[test]
391    fn secret_validation_rejects_invalid_env_var_names() {
392        let cases = [
393            ("", SecretConfigError::EmptyEnvVar { secret_index: 0 }),
394            (
395                "API=KEY",
396                SecretConfigError::EnvVarContainsEquals { secret_index: 0 },
397            ),
398            (
399                "API\0KEY",
400                SecretConfigError::EnvVarContainsNul { secret_index: 0 },
401            ),
402        ];
403
404        for (env_var, expected) in cases {
405            let mut entry = valid_secret();
406            entry.env_var = env_var.into();
407            assert_eq!(entry.validate(0), Err(expected));
408        }
409    }
410
411    #[test]
412    fn secret_validation_rejects_missing_allowed_hosts() {
413        let mut entry = valid_secret();
414        entry.allowed_hosts.clear();
415
416        assert_eq!(
417            entry.validate(0),
418            Err(SecretConfigError::MissingAllowedHosts { secret_index: 0 })
419        );
420    }
421
422    #[test]
423    fn secret_validation_rejects_invalid_placeholders() {
424        let too_long = "x".repeat(MAX_SECRET_PLACEHOLDER_BYTES + 1);
425        let cases = [
426            ("", SecretConfigError::EmptyPlaceholder { secret_index: 0 }),
427            (
428                too_long.as_str(),
429                SecretConfigError::PlaceholderTooLong {
430                    secret_index: 0,
431                    actual_bytes: MAX_SECRET_PLACEHOLDER_BYTES + 1,
432                    max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
433                },
434            ),
435            (
436                "abc\0def",
437                SecretConfigError::PlaceholderContainsNul { secret_index: 0 },
438            ),
439            (
440                "abc\rdef",
441                SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
442            ),
443            (
444                "abc\ndef",
445                SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
446            ),
447        ];
448
449        for (placeholder, expected) in cases {
450            let mut entry = valid_secret();
451            entry.placeholder = placeholder.into();
452            assert_eq!(entry.validate(0), Err(expected));
453        }
454    }
455
456    #[test]
457    fn violation_action_serializes_with_sdk_casing() {
458        let action = ViolationAction::Passthrough(vec![
459            HostPattern::Exact("api.anthropic.com".into()),
460            HostPattern::Wildcard("*.anthropic.com".into()),
461            HostPattern::Any,
462        ]);
463
464        assert_eq!(
465            serde_json::to_string(&action).unwrap(),
466            r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
467        );
468        assert_eq!(
469            serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
470            r#""block-and-log""#
471        );
472        assert_eq!(
473            serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
474            r#""block-and-terminate""#
475        );
476    }
477
478    #[test]
479    fn violation_action_accepts_legacy_pascal_case() {
480        let action: ViolationAction =
481            serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();
482
483        assert_eq!(
484            action,
485            ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
486        );
487        assert_eq!(
488            serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
489            ViolationAction::BlockAndTerminate
490        );
491    }
492}