Skip to main content

microsandbox_network/secrets/
handler.rs

1//! Secret substitution handler for the TLS proxy.
2//!
3//! Scans decrypted plaintext for placeholder strings and replaces them
4//! with real secret values, but only when the destination host is allowed.
5
6use std::borrow::Cow;
7
8use super::config::{SecretsConfig, ViolationAction};
9
10//--------------------------------------------------------------------------------------------------
11// Types
12//--------------------------------------------------------------------------------------------------
13
14/// Handles secret placeholder substitution in TLS-intercepted plaintext.
15///
16/// Created from [`SecretsConfig`] and the destination SNI. Determines which
17/// secrets are eligible for this connection based on host matching.
18pub struct SecretsHandler {
19    /// Secrets eligible for substitution on this connection.
20    eligible: Vec<EligibleSecret>,
21    /// All placeholder strings (for violation detection on disallowed hosts).
22    all_placeholders: Vec<String>,
23    /// Violation action.
24    on_violation: ViolationAction,
25    /// Whether any ineligible secrets exist (pre-computed for fast-path skip).
26    has_ineligible: bool,
27    /// Whether this connection is TLS-intercepted (not bypass).
28    tls_intercepted: bool,
29}
30
31/// A secret that passed host matching for this connection.
32struct 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
42//--------------------------------------------------------------------------------------------------
43// Methods
44//--------------------------------------------------------------------------------------------------
45
46impl SecretsHandler {
47    /// Create a handler for a specific connection.
48    ///
49    /// Filters secrets by host matching against the SNI. Only secrets
50    /// whose `allowed_hosts` match `sni` will be substituted.
51    /// `tls_intercepted` indicates whether this is a MITM connection
52    /// (true) or a bypass/plain connection (false).
53    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    /// Substitute secrets in plaintext data (guest → server direction).
88    ///
89    /// Splits the HTTP message on `\r\n\r\n` to scope substitution:
90    /// - `headers`: substitutes in the header portion (before boundary)
91    /// - `basic_auth`: substitutes in Authorization headers specifically
92    /// - `query_params`: substitutes in the request line (first line, query portion)
93    /// - `body`: substitutes in the body portion (after boundary)
94    ///
95    /// Returns `None` if a violation is detected (placeholder going to a
96    /// disallowed host) or `BlockAndTerminate` is triggered.
97    pub fn substitute<'a>(&self, data: &'a [u8]) -> Option<Cow<'a, [u8]>> {
98        // Fast path: skip violation check when no ineligible secrets exist.
99        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            // No substitution needed — return borrowed slice (zero-copy).
122            return Some(Cow::Borrowed(data));
123        }
124
125        // Split raw bytes at the header boundary BEFORE converting to owned strings.
126        // This avoids position shifts from from_utf8_lossy replacement chars.
127        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            // Skip secrets that require TLS identity on non-intercepted connections.
141            if secret.require_tls_identity && !self.tls_intercepted {
142                continue;
143            }
144
145            if boundary.is_some() {
146                // Header portion: substitute based on headers/basic_auth/query_params scopes.
147                if secret.inject_headers || secret.inject_basic_auth || secret.inject_query_params {
148                    // Guard: only allocate a new String if the placeholder is actually present.
149                    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                // Body portion.
162                if secret.inject_body && body_str.contains(&secret.placeholder) {
163                    body_str = body_str.replace(&secret.placeholder, &secret.value);
164                }
165            } else {
166                // No boundary found — treat entire message as headers.
167                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 body substitution changed the length, update Content-Length.
174        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    /// Returns true if no secrets are configured.
184    pub fn is_empty(&self) -> bool {
185        self.all_placeholders.is_empty()
186    }
187
188    /// Returns true if a violation should terminate the sandbox.
189    pub fn terminates_on_violation(&self) -> bool {
190        matches!(self.on_violation, ViolationAction::BlockAndTerminate)
191    }
192}
193
194impl SecretsHandler {
195    /// Check if any placeholder appears in data for a host that isn't allowed.
196    fn has_violation(&self, text: &str) -> bool {
197        // Fast path: if all placeholders have matching eligible entries, no
198        // violation is possible (every secret is allowed for this host).
199        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
215//--------------------------------------------------------------------------------------------------
216// Functions
217//--------------------------------------------------------------------------------------------------
218
219/// Substitute a placeholder in the headers portion with scoping:
220/// - `headers`: replace anywhere in headers
221/// - `basic_auth`: replace only in Authorization header lines
222/// - `query_params`: replace only in the request line's query string
223fn 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        // Replace everywhere in headers.
233        return headers.replace(placeholder, value);
234    }
235
236    // Line-by-line scoping.
237    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            // Request line — substitute in query portion.
245            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            // Authorization header — substitute.
253            result.push_str(&line.replace(placeholder, value));
254        } else {
255            result.push_str(line);
256        }
257    }
258
259    result
260}
261
262/// Update the Content-Length header value in `headers` to `new_len`.
263///
264/// Performs a case-insensitive line scan. If no Content-Length header exists
265/// (e.g. chunked transfer encoding), the headers are returned unchanged.
266fn 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
285/// Find the `\r\n\r\n` boundary between HTTP headers and body.
286fn 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//--------------------------------------------------------------------------------------------------
293// Tests
294//--------------------------------------------------------------------------------------------------
295
296#[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        // No Content-Length header (e.g. chunked).
399        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        // Body unchanged, Content-Length should stay 5.
416        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        // tls_intercepted = false — secret requires TLS identity
434        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        // Placeholder should NOT be substituted.
439        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        // Authorization header should be substituted.
462        assert!(result.contains("Authorization: Bearer real-secret"));
463        // Other headers should NOT be substituted.
464        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        // Request line should be substituted.
483        assert!(result.contains("GET /api?key=real-secret HTTP/1.1"));
484        // Other headers should NOT be substituted.
485    }
486}