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        let mut output = header_str;
174        output.push_str(&body_str);
175        Some(Cow::Owned(output.into_bytes()))
176    }
177
178    /// Returns true if no secrets are configured.
179    pub fn is_empty(&self) -> bool {
180        self.all_placeholders.is_empty()
181    }
182
183    /// Returns true if a violation should terminate the sandbox.
184    pub fn terminates_on_violation(&self) -> bool {
185        matches!(self.on_violation, ViolationAction::BlockAndTerminate)
186    }
187}
188
189impl SecretsHandler {
190    /// Check if any placeholder appears in data for a host that isn't allowed.
191    fn has_violation(&self, text: &str) -> bool {
192        // Fast path: if all placeholders have matching eligible entries, no
193        // violation is possible (every secret is allowed for this host).
194        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
210//--------------------------------------------------------------------------------------------------
211// Functions
212//--------------------------------------------------------------------------------------------------
213
214/// Substitute a placeholder in the headers portion with scoping:
215/// - `headers`: replace anywhere in headers
216/// - `basic_auth`: replace only in Authorization header lines
217/// - `query_params`: replace only in the request line's query string
218fn 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        // Replace everywhere in headers.
228        return headers.replace(placeholder, value);
229    }
230
231    // Line-by-line scoping.
232    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            // Request line — substitute in query portion.
240            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            // Authorization header — substitute.
248            result.push_str(&line.replace(placeholder, value));
249        } else {
250            result.push_str(line);
251        }
252    }
253
254    result
255}
256
257/// Find the `\r\n\r\n` boundary between HTTP headers and body.
258fn 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//--------------------------------------------------------------------------------------------------
265// Tests
266//--------------------------------------------------------------------------------------------------
267
268#[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        // tls_intercepted = false — secret requires TLS identity
356        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        // Placeholder should NOT be substituted.
361        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        // Authorization header should be substituted.
384        assert!(result.contains("Authorization: Bearer real-secret"));
385        // Other headers should NOT be substituted.
386        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        // Request line should be substituted.
405        assert!(result.contains("GET /api?key=real-secret HTTP/1.1"));
406        // Other headers should NOT be substituted.
407    }
408}