microsandbox_network/secrets/
handler.rs1use std::borrow::Cow;
7
8use super::config::{SecretsConfig, ViolationAction};
9
10pub struct SecretsHandler {
19 eligible: Vec<EligibleSecret>,
21 all_placeholders: Vec<String>,
23 on_violation: ViolationAction,
25 has_ineligible: bool,
27 tls_intercepted: bool,
29}
30
31struct EligibleSecret {
33 placeholder: String,
34 value: String,
35 inject_headers: bool,
36 inject_basic_auth: bool,
37 inject_query_params: bool,
38 inject_body: bool,
39 require_tls_identity: bool,
40}
41
42impl SecretsHandler {
47 pub fn new(config: &SecretsConfig, sni: &str, tls_intercepted: bool) -> Self {
54 let mut eligible = Vec::new();
55 let mut all_placeholders = Vec::new();
56
57 for secret in &config.secrets {
58 all_placeholders.push(secret.placeholder.clone());
59
60 let host_allowed = secret.allowed_hosts.is_empty()
61 || secret.allowed_hosts.iter().any(|p| p.matches(sni));
62
63 if host_allowed {
64 eligible.push(EligibleSecret {
65 placeholder: secret.placeholder.clone(),
66 value: secret.value.clone(),
67 inject_headers: secret.injection.headers,
68 inject_basic_auth: secret.injection.basic_auth,
69 inject_query_params: secret.injection.query_params,
70 inject_body: secret.injection.body,
71 require_tls_identity: secret.require_tls_identity,
72 });
73 }
74 }
75
76 let has_ineligible = eligible.len() < all_placeholders.len();
77
78 Self {
79 eligible,
80 all_placeholders,
81 on_violation: config.on_violation.clone(),
82 has_ineligible,
83 tls_intercepted,
84 }
85 }
86
87 pub fn substitute<'a>(&self, data: &'a [u8]) -> Option<Cow<'a, [u8]>> {
98 if self.has_ineligible {
100 let text = String::from_utf8_lossy(data);
101 if self.has_violation(&text) {
102 match self.on_violation {
103 ViolationAction::Block => return None,
104 ViolationAction::BlockAndLog => {
105 tracing::warn!(
106 "secret violation: placeholder detected for disallowed host"
107 );
108 return None;
109 }
110 ViolationAction::BlockAndTerminate => {
111 tracing::error!(
112 "secret violation: placeholder detected for disallowed host — terminating"
113 );
114 return None;
115 }
116 }
117 }
118 }
119
120 if self.eligible.is_empty() {
121 return Some(Cow::Borrowed(data));
123 }
124
125 let boundary = find_header_boundary(data);
128 let (header_bytes, body_bytes) = match boundary {
129 Some(pos) => (&data[..pos], &data[pos..]),
130 None => (data, &[] as &[u8]),
131 };
132 let mut header_str = String::from_utf8_lossy(header_bytes).into_owned();
133 let mut body_str = if boundary.is_some() {
134 String::from_utf8_lossy(body_bytes).into_owned()
135 } else {
136 String::new()
137 };
138
139 for secret in &self.eligible {
140 if secret.require_tls_identity && !self.tls_intercepted {
142 continue;
143 }
144
145 if boundary.is_some() {
146 if secret.inject_headers || secret.inject_basic_auth || secret.inject_query_params {
148 if header_str.contains(&secret.placeholder) {
150 header_str = substitute_in_headers(
151 &header_str,
152 &secret.placeholder,
153 &secret.value,
154 secret.inject_headers,
155 secret.inject_basic_auth,
156 secret.inject_query_params,
157 );
158 }
159 }
160
161 if secret.inject_body && body_str.contains(&secret.placeholder) {
163 body_str = body_str.replace(&secret.placeholder, &secret.value);
164 }
165 } else {
166 if secret.inject_headers && header_str.contains(&secret.placeholder) {
168 header_str = header_str.replace(&secret.placeholder, &secret.value);
169 }
170 }
171 }
172
173 if boundary.is_some() && body_str.len() != body_bytes.len() {
175 header_str = update_content_length(&header_str, body_str.len());
176 }
177
178 let mut output = header_str;
179 output.push_str(&body_str);
180 Some(Cow::Owned(output.into_bytes()))
181 }
182
183 pub fn is_empty(&self) -> bool {
185 self.all_placeholders.is_empty()
186 }
187
188 pub fn terminates_on_violation(&self) -> bool {
190 matches!(self.on_violation, ViolationAction::BlockAndTerminate)
191 }
192}
193
194impl SecretsHandler {
195 fn has_violation(&self, text: &str) -> bool {
197 if self.eligible.len() == self.all_placeholders.len() {
200 return false;
201 }
202
203 for placeholder in &self.all_placeholders {
204 if text.contains(placeholder.as_str())
205 && !self.eligible.iter().any(|s| s.placeholder == *placeholder)
206 {
207 return true;
208 }
209 }
210
211 false
212 }
213}
214
215fn substitute_in_headers(
224 headers: &str,
225 placeholder: &str,
226 value: &str,
227 inject_all_headers: bool,
228 inject_basic_auth: bool,
229 inject_query_params: bool,
230) -> String {
231 if inject_all_headers {
232 return headers.replace(placeholder, value);
234 }
235
236 let mut result = String::with_capacity(headers.len());
238 for (i, line) in headers.split("\r\n").enumerate() {
239 if i > 0 {
240 result.push_str("\r\n");
241 }
242
243 if i == 0 && inject_query_params {
244 result.push_str(&line.replace(placeholder, value));
246 } else if inject_basic_auth
247 && line
248 .as_bytes()
249 .get(..14)
250 .is_some_and(|b| b.eq_ignore_ascii_case(b"authorization:"))
251 {
252 result.push_str(&line.replace(placeholder, value));
254 } else {
255 result.push_str(line);
256 }
257 }
258
259 result
260}
261
262fn update_content_length(headers: &str, new_len: usize) -> String {
267 let mut result = String::with_capacity(headers.len());
268 for (i, line) in headers.split("\r\n").enumerate() {
269 if i > 0 {
270 result.push_str("\r\n");
271 }
272 if line
273 .as_bytes()
274 .get(..15)
275 .is_some_and(|b| b.eq_ignore_ascii_case(b"content-length:"))
276 {
277 result.push_str(&format!("Content-Length: {new_len}"));
278 } else {
279 result.push_str(line);
280 }
281 }
282 result
283}
284
285fn find_header_boundary(data: &[u8]) -> Option<usize> {
287 data.windows(4)
288 .position(|w| w == b"\r\n\r\n")
289 .map(|pos| pos + 4)
290}
291
292#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::secrets::config::*;
300
301 fn make_config(secrets: Vec<SecretEntry>) -> SecretsConfig {
302 SecretsConfig {
303 secrets,
304 on_violation: ViolationAction::Block,
305 }
306 }
307
308 fn make_secret(placeholder: &str, value: &str, host: &str) -> SecretEntry {
309 SecretEntry {
310 env_var: "TEST_KEY".into(),
311 value: value.into(),
312 placeholder: placeholder.into(),
313 allowed_hosts: vec![HostPattern::Exact(host.into())],
314 injection: SecretInjection::default(),
315 require_tls_identity: true,
316 }
317 }
318
319 #[test]
320 fn substitute_in_headers() {
321 let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
322 let handler = SecretsHandler::new(&config, "api.openai.com", true);
323
324 let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
325 let output = handler.substitute(input).unwrap();
326 assert_eq!(
327 String::from_utf8(output.into_owned()).unwrap(),
328 "GET / HTTP/1.1\r\nAuthorization: Bearer real-secret\r\n\r\n"
329 );
330 }
331
332 #[test]
333 fn no_substitute_for_wrong_host() {
334 let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
335 let handler = SecretsHandler::new(&config, "evil.com", true);
336
337 let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
338 assert!(handler.substitute(input).is_none());
339 }
340
341 #[test]
342 fn body_injection_disabled_by_default() {
343 let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
344 let handler = SecretsHandler::new(&config, "api.openai.com", true);
345
346 let input = b"POST / HTTP/1.1\r\n\r\n{\"key\": \"$KEY\"}";
347 let output = handler.substitute(input).unwrap();
348 assert!(
349 String::from_utf8(output.into_owned())
350 .unwrap()
351 .contains("$KEY")
352 );
353 }
354
355 #[test]
356 fn body_injection_when_enabled() {
357 let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
358 secret.injection.body = true;
359 let config = make_config(vec![secret]);
360 let handler = SecretsHandler::new(&config, "api.openai.com", true);
361
362 let input = b"POST / HTTP/1.1\r\n\r\n{\"key\": \"$KEY\"}";
363 let output = handler.substitute(input).unwrap();
364 assert_eq!(
365 String::from_utf8(output.into_owned()).unwrap(),
366 "POST / HTTP/1.1\r\n\r\n{\"key\": \"real-secret\"}"
367 );
368 }
369
370 #[test]
371 fn body_injection_updates_content_length() {
372 let mut secret = make_secret("$KEY", "a]longer]secret]value", "api.openai.com");
373 secret.injection.body = true;
374 let config = make_config(vec![secret]);
375 let handler = SecretsHandler::new(&config, "api.openai.com", true);
376
377 let body = "{\"key\": \"$KEY\"}";
378 let input = format!(
379 "POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}",
380 body.len(),
381 body
382 );
383 let output = handler.substitute(input.as_bytes()).unwrap();
384 let result = String::from_utf8(output.into_owned()).unwrap();
385
386 let expected_body = "{\"key\": \"a]longer]secret]value\"}";
387 assert!(result.contains(expected_body));
388 assert!(result.contains(&format!("Content-Length: {}", expected_body.len())));
389 }
390
391 #[test]
392 fn body_injection_no_content_length_header() {
393 let mut secret = make_secret("$KEY", "longer-secret", "api.openai.com");
394 secret.injection.body = true;
395 let config = make_config(vec![secret]);
396 let handler = SecretsHandler::new(&config, "api.openai.com", true);
397
398 let input = b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n{\"key\": \"$KEY\"}";
400 let output = handler.substitute(input).unwrap();
401 let result = String::from_utf8(output.into_owned()).unwrap();
402 assert!(result.contains("longer-secret"));
403 assert!(!result.contains("Content-Length"));
404 }
405
406 #[test]
407 fn header_only_substitution_preserves_content_length() {
408 let config = make_config(vec![make_secret("$KEY", "longer-value", "api.openai.com")]);
409 let handler = SecretsHandler::new(&config, "api.openai.com", true);
410
411 let input =
412 b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\nContent-Length: 5\r\n\r\nhello";
413 let output = handler.substitute(input).unwrap();
414 let result = String::from_utf8(output.into_owned()).unwrap();
415 assert!(result.contains("Content-Length: 5"));
417 assert!(result.ends_with("hello"));
418 }
419
420 #[test]
421 fn no_secrets_passthrough() {
422 let config = make_config(vec![]);
423 let handler = SecretsHandler::new(&config, "anything.com", true);
424
425 let input = b"GET / HTTP/1.1\r\n\r\n";
426 let output = handler.substitute(input).unwrap();
427 assert_eq!(&*output, input);
428 }
429
430 #[test]
431 fn require_tls_identity_blocks_on_non_intercepted() {
432 let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
433 let handler = SecretsHandler::new(&config, "api.openai.com", false);
435
436 let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
437 let output = handler.substitute(input).unwrap();
438 assert!(
440 String::from_utf8(output.into_owned())
441 .unwrap()
442 .contains("$KEY")
443 );
444 }
445
446 #[test]
447 fn basic_auth_only_substitution() {
448 let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
449 secret.injection = SecretInjection {
450 headers: false,
451 basic_auth: true,
452 query_params: false,
453 body: false,
454 };
455 let config = make_config(vec![secret]);
456 let handler = SecretsHandler::new(&config, "api.openai.com", true);
457
458 let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\nX-Custom: $KEY\r\n\r\n";
459 let output = handler.substitute(input).unwrap();
460 let result = String::from_utf8(output.into_owned()).unwrap();
461 assert!(result.contains("Authorization: Bearer real-secret"));
463 assert!(result.contains("X-Custom: $KEY"));
465 }
466
467 #[test]
468 fn query_params_substitution() {
469 let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
470 secret.injection = SecretInjection {
471 headers: false,
472 basic_auth: false,
473 query_params: true,
474 body: false,
475 };
476 let config = make_config(vec![secret]);
477 let handler = SecretsHandler::new(&config, "api.openai.com", true);
478
479 let input = b"GET /api?key=$KEY HTTP/1.1\r\nHost: api.openai.com\r\n\r\n";
480 let output = handler.substitute(input).unwrap();
481 let result = String::from_utf8(output.into_owned()).unwrap();
482 assert!(result.contains("GET /api?key=real-secret HTTP/1.1"));
484 }
486}