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 let mut output = header_str;
174 output.push_str(&body_str);
175 Some(Cow::Owned(output.into_bytes()))
176 }
177
178 pub fn is_empty(&self) -> bool {
180 self.all_placeholders.is_empty()
181 }
182
183 pub fn terminates_on_violation(&self) -> bool {
185 matches!(self.on_violation, ViolationAction::BlockAndTerminate)
186 }
187}
188
189impl SecretsHandler {
190 fn has_violation(&self, text: &str) -> bool {
192 if self.eligible.len() == self.all_placeholders.len() {
195 return false;
196 }
197
198 for placeholder in &self.all_placeholders {
199 if text.contains(placeholder.as_str())
200 && !self.eligible.iter().any(|s| s.placeholder == *placeholder)
201 {
202 return true;
203 }
204 }
205
206 false
207 }
208}
209
210fn substitute_in_headers(
219 headers: &str,
220 placeholder: &str,
221 value: &str,
222 inject_all_headers: bool,
223 inject_basic_auth: bool,
224 inject_query_params: bool,
225) -> String {
226 if inject_all_headers {
227 return headers.replace(placeholder, value);
229 }
230
231 let mut result = String::with_capacity(headers.len());
233 for (i, line) in headers.split("\r\n").enumerate() {
234 if i > 0 {
235 result.push_str("\r\n");
236 }
237
238 if i == 0 && inject_query_params {
239 result.push_str(&line.replace(placeholder, value));
241 } else if inject_basic_auth
242 && line
243 .as_bytes()
244 .get(..14)
245 .is_some_and(|b| b.eq_ignore_ascii_case(b"authorization:"))
246 {
247 result.push_str(&line.replace(placeholder, value));
249 } else {
250 result.push_str(line);
251 }
252 }
253
254 result
255}
256
257fn find_header_boundary(data: &[u8]) -> Option<usize> {
259 data.windows(4)
260 .position(|w| w == b"\r\n\r\n")
261 .map(|pos| pos + 4)
262}
263
264#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::secrets::config::*;
272
273 fn make_config(secrets: Vec<SecretEntry>) -> SecretsConfig {
274 SecretsConfig {
275 secrets,
276 on_violation: ViolationAction::Block,
277 }
278 }
279
280 fn make_secret(placeholder: &str, value: &str, host: &str) -> SecretEntry {
281 SecretEntry {
282 env_var: "TEST_KEY".into(),
283 value: value.into(),
284 placeholder: placeholder.into(),
285 allowed_hosts: vec![HostPattern::Exact(host.into())],
286 injection: SecretInjection::default(),
287 require_tls_identity: true,
288 }
289 }
290
291 #[test]
292 fn substitute_in_headers() {
293 let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
294 let handler = SecretsHandler::new(&config, "api.openai.com", true);
295
296 let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
297 let output = handler.substitute(input).unwrap();
298 assert_eq!(
299 String::from_utf8(output.into_owned()).unwrap(),
300 "GET / HTTP/1.1\r\nAuthorization: Bearer real-secret\r\n\r\n"
301 );
302 }
303
304 #[test]
305 fn no_substitute_for_wrong_host() {
306 let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
307 let handler = SecretsHandler::new(&config, "evil.com", true);
308
309 let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
310 assert!(handler.substitute(input).is_none());
311 }
312
313 #[test]
314 fn body_injection_disabled_by_default() {
315 let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
316 let handler = SecretsHandler::new(&config, "api.openai.com", true);
317
318 let input = b"POST / HTTP/1.1\r\n\r\n{\"key\": \"$KEY\"}";
319 let output = handler.substitute(input).unwrap();
320 assert!(
321 String::from_utf8(output.into_owned())
322 .unwrap()
323 .contains("$KEY")
324 );
325 }
326
327 #[test]
328 fn body_injection_when_enabled() {
329 let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
330 secret.injection.body = true;
331 let config = make_config(vec![secret]);
332 let handler = SecretsHandler::new(&config, "api.openai.com", true);
333
334 let input = b"POST / HTTP/1.1\r\n\r\n{\"key\": \"$KEY\"}";
335 let output = handler.substitute(input).unwrap();
336 assert_eq!(
337 String::from_utf8(output.into_owned()).unwrap(),
338 "POST / HTTP/1.1\r\n\r\n{\"key\": \"real-secret\"}"
339 );
340 }
341
342 #[test]
343 fn no_secrets_passthrough() {
344 let config = make_config(vec![]);
345 let handler = SecretsHandler::new(&config, "anything.com", true);
346
347 let input = b"GET / HTTP/1.1\r\n\r\n";
348 let output = handler.substitute(input).unwrap();
349 assert_eq!(&*output, input);
350 }
351
352 #[test]
353 fn require_tls_identity_blocks_on_non_intercepted() {
354 let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
355 let handler = SecretsHandler::new(&config, "api.openai.com", false);
357
358 let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
359 let output = handler.substitute(input).unwrap();
360 assert!(
362 String::from_utf8(output.into_owned())
363 .unwrap()
364 .contains("$KEY")
365 );
366 }
367
368 #[test]
369 fn basic_auth_only_substitution() {
370 let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
371 secret.injection = SecretInjection {
372 headers: false,
373 basic_auth: true,
374 query_params: false,
375 body: false,
376 };
377 let config = make_config(vec![secret]);
378 let handler = SecretsHandler::new(&config, "api.openai.com", true);
379
380 let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\nX-Custom: $KEY\r\n\r\n";
381 let output = handler.substitute(input).unwrap();
382 let result = String::from_utf8(output.into_owned()).unwrap();
383 assert!(result.contains("Authorization: Bearer real-secret"));
385 assert!(result.contains("X-Custom: $KEY"));
387 }
388
389 #[test]
390 fn query_params_substitution() {
391 let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
392 secret.injection = SecretInjection {
393 headers: false,
394 basic_auth: false,
395 query_params: true,
396 body: false,
397 };
398 let config = make_config(vec![secret]);
399 let handler = SecretsHandler::new(&config, "api.openai.com", true);
400
401 let input = b"GET /api?key=$KEY HTTP/1.1\r\nHost: api.openai.com\r\n\r\n";
402 let output = handler.substitute(input).unwrap();
403 let result = String::from_utf8(output.into_owned()).unwrap();
404 assert!(result.contains("GET /api?key=real-secret HTTP/1.1"));
406 }
408}