microsandbox_network/secrets/
config.rs1use serde::{Deserialize, Serialize};
4use zeroize::Zeroizing;
5
6pub use microsandbox_types::SecretSource;
10
11pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct SecretsConfig {
25 #[serde(default)]
27 pub secrets: Vec<SecretEntry>,
28
29 #[serde(default)]
31 pub on_violation: ViolationAction,
32}
33
34#[derive(Clone, Serialize, Deserialize)]
36pub struct SecretEntry {
37 pub env_var: String,
43
44 #[serde(default = "empty_secret_value")]
54 pub value: Zeroizing<String>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub source: Option<SecretSource>,
61
62 pub placeholder: String,
67
68 #[serde(default)]
70 pub allowed_hosts: Vec<HostPattern>,
71
72 #[serde(default)]
74 pub injection: SecretInjection,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub on_violation: Option<ViolationAction>,
79
80 #[serde(default = "default_true")]
84 pub require_tls_identity: bool,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum HostPattern {
91 #[serde(alias = "Exact")]
93 Exact(String),
94 #[serde(alias = "Wildcard")]
96 Wildcard(String),
97 #[serde(alias = "Any")]
99 Any,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
104pub enum SecretConfigError {
105 #[error("secret #{secret_index}: env_var must not be empty")]
107 EmptyEnvVar {
108 secret_index: usize,
110 },
111
112 #[error("secret #{secret_index}: env_var must not contain `=`")]
114 EnvVarContainsEquals {
115 secret_index: usize,
117 },
118
119 #[error("secret #{secret_index}: env_var must not contain NUL")]
121 EnvVarContainsNul {
122 secret_index: usize,
124 },
125
126 #[error("secret #{secret_index}: at least one allowed host is required")]
128 MissingAllowedHosts {
129 secret_index: usize,
131 },
132
133 #[error("secret #{secret_index}: placeholder must not be empty")]
135 EmptyPlaceholder {
136 secret_index: usize,
138 },
139
140 #[error(
142 "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
143 )]
144 PlaceholderTooLong {
145 secret_index: usize,
147 actual_bytes: usize,
149 max_bytes: usize,
151 },
152
153 #[error("secret #{secret_index}: placeholder must not contain NUL")]
155 PlaceholderContainsNul {
156 secret_index: usize,
158 },
159
160 #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
162 PlaceholderContainsLineBreak {
163 secret_index: usize,
165 },
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct SecretInjection {
171 #[serde(default = "default_true")]
173 pub headers: bool,
174
175 #[serde(default = "default_true")]
177 pub basic_auth: bool,
178
179 #[serde(default)]
181 pub query_params: bool,
182
183 #[serde(default)]
191 pub body: bool,
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum ViolationAction {
198 #[serde(alias = "Block")]
200 Block,
201 #[default]
203 #[serde(alias = "BlockAndLog", alias = "block_and_log")]
204 BlockAndLog,
205 #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
207 BlockAndTerminate,
208 #[serde(alias = "Passthrough")]
210 Passthrough(Vec<HostPattern>),
211}
212
213impl SecretsConfig {
218 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 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 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 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 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 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
317impl 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
332fn 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#[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}