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;
7use std::collections::{HashMap, HashSet};
8use std::net::IpAddr;
9
10use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
11use httlib_hpack::{Decoder as HpackDecoder, Encoder as HpackEncoder};
12use percent_encoding::percent_decode;
13
14use super::config::{
15    HostPattern, MAX_SECRET_PLACEHOLDER_BYTES, SecretEntry, SecretsConfig, ViolationAction,
16};
17use crate::shared::SharedState;
18
19//--------------------------------------------------------------------------------------------------
20// Constants
21//--------------------------------------------------------------------------------------------------
22
23/// Maximum bytes to buffer while waiting for HTTP request headers.
24const MAX_HTTP_HEADER_BYTES: usize = 64 * 1024;
25
26/// Maximum fixed-length HTTP body to buffer for body substitution.
27const MAX_HTTP_BODY_BUFFER_BYTES: usize = 16 * 1024 * 1024;
28
29/// HTTP/2 client connection preface.
30const HTTP2_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
31
32/// Maximum HTTP/2 frame payload the handler buffers at once.
33/// This is the largest value representable in the protocol's 24-bit
34/// frame-length field.
35const MAX_HTTP2_FRAME_PAYLOAD_BYTES: usize = 0x00ff_ffff;
36
37/// Maximum accumulated HTTP/2 HPACK header block.
38const MAX_HTTP2_HEADER_BLOCK_BYTES: usize = 64 * 1024;
39
40/// Maximum decoded HTTP/2 header bytes accepted after HPACK expansion.
41const MAX_HTTP2_DECODED_HEADER_BYTES: usize = 64 * 1024;
42
43/// Maximum decoded HTTP/2 header fields accepted in one HEADERS block.
44const MAX_HTTP2_HEADER_FIELDS: usize = 1024;
45
46/// Maximum concurrently open HTTP/2 request streams tracked by the secret handler.
47const MAX_HTTP2_TRACKED_STREAMS: usize = 1024;
48
49/// Conservative outbound HTTP/2 frame payload size. This is the protocol
50/// default and is valid even before seeing the upstream peer's SETTINGS.
51const HTTP2_OUTBOUND_FRAME_PAYLOAD_BYTES: usize = 16 * 1024;
52
53const HTTP2_FRAME_DATA: u8 = 0x0;
54const HTTP2_FRAME_HEADERS: u8 = 0x1;
55const HTTP2_FRAME_PUSH_PROMISE: u8 = 0x5;
56const HTTP2_FRAME_CONTINUATION: u8 = 0x9;
57
58const HTTP2_FLAG_END_STREAM: u8 = 0x1;
59const HTTP2_FLAG_END_HEADERS: u8 = 0x4;
60const HTTP2_FLAG_PADDED: u8 = 0x8;
61const HTTP2_FLAG_PRIORITY: u8 = 0x20;
62
63//--------------------------------------------------------------------------------------------------
64// Types
65//--------------------------------------------------------------------------------------------------
66
67/// Handles secret placeholder substitution in TLS-intercepted plaintext.
68///
69/// Created from [`SecretsConfig`] and the destination SNI. Determines which
70/// secrets are eligible for this connection based on host matching.
71pub struct SecretsHandler {
72    /// Secrets eligible for substitution on this connection.
73    eligible_for_substitution: Vec<EligibleSecret>,
74    /// Secret placeholders that should trigger an effective blocking action.
75    ineligible_for_substitution: Vec<IneligibleSecret>,
76    /// Whether this connection is TLS-intercepted (not bypass).
77    tls_intercepted: bool,
78    /// Longest raw or encoded placeholder representation. Sizes the
79    /// sliding-window tail used for cross-write violation detection.
80    max_detection_window_len: usize,
81    /// Longest active body-injection placeholder. Sizes the chunked body
82    /// substitution carry window.
83    max_body_placeholder_len: usize,
84    /// True when any configured placeholder exceeds the supported bound.
85    placeholder_limit_exceeded: bool,
86    /// Trailing bytes carried over from the previous `substitute` call so a
87    /// placeholder split across TCP writes still trips the violation check.
88    /// Capped at `max_detection_window_len - 1` bytes.
89    prev_tail: Vec<u8>,
90    /// HTTP framing state for the request stream. Tracks whether the next
91    /// chunk should be parsed as a request start (headers) or treated as a
92    /// continuation of the current request's body.
93    http_state: HttpState,
94    /// SNI to require in HTTP/1 `Host` headers for DNS-pinned intercepted TLS.
95    http_sni: Option<String>,
96    /// Buffered HTTP bytes while waiting for complete headers or a complete
97    /// body-rewriteable request.
98    http_pending: Vec<u8>,
99    /// HTTP/2 parser/rewriter state once an HTTP/2 preface is observed.
100    http2_state: Option<Http2State>,
101}
102
103/// HTTP request framing state for the guest→server byte stream.
104#[derive(Debug, Clone)]
105enum HttpState {
106    /// Scanning for the start of a request. The next `\r\n\r\n` ends headers.
107    AwaitingHeaders,
108    /// Inside a fixed-length request body. `remaining` is the number of body
109    /// bytes left per Content-Length.
110    InBody { remaining: usize },
111    /// Inside a chunked request body.
112    InChunkedBody { state: ChunkedBodyState },
113    /// Inside a chunked request body that is being decoded and re-encoded so
114    /// body placeholders can be substituted safely.
115    InChunkedRewriteBody { state: ChunkedRewriteState },
116    /// Buffering a fixed-length body so body substitution can update
117    /// `Content-Length` against the complete rewritten request.
118    BufferingBody { remaining: usize },
119}
120
121/// Stateful chunked transfer parser for request bodies.
122#[derive(Debug, Clone, Default)]
123struct ChunkedBodyState {
124    phase: ChunkedPhase,
125    line: Vec<u8>,
126    decoded_tail: Vec<u8>,
127}
128
129/// Stateful chunked transfer rewriter for request bodies.
130#[derive(Debug, Clone, Default)]
131struct ChunkedRewriteState {
132    parser: ChunkedBodyState,
133    substitution_tail: Vec<u8>,
134}
135
136/// Stateful HTTP/2 client-to-server frame parser.
137struct Http2State {
138    preface_seen: bool,
139    buffer: Vec<u8>,
140    header_block: Option<Http2HeaderBlock>,
141    open_request_streams: HashSet<u32>,
142    data_tails: HashMap<u32, Vec<u8>>,
143    decoder: HpackDecoder<'static>,
144    encoder: HpackEncoder<'static>,
145}
146
147/// Accumulated HEADERS/CONTINUATION block for one stream.
148struct Http2HeaderBlock {
149    stream_id: u32,
150    end_stream: bool,
151    block: Vec<u8>,
152}
153
154/// Parsed HTTP/2 frame view.
155struct Http2Frame<'a> {
156    kind: u8,
157    flags: u8,
158    stream_id: u32,
159    payload: &'a [u8],
160    raw: &'a [u8],
161}
162
163type Http2Headers = Vec<(Vec<u8>, Vec<u8>)>;
164
165/// Current chunked-body parser phase.
166#[derive(Debug, Clone, Default)]
167enum ChunkedPhase {
168    /// Reading a chunk-size line.
169    #[default]
170    SizeLine,
171    /// Reading exactly `remaining` chunk-data bytes.
172    Data { remaining: usize },
173    /// Reading the CRLF after chunk data.
174    DataCrlf { seen_cr: bool },
175    /// Reading trailer lines until the empty line.
176    TrailerLine,
177}
178
179/// DNS-pinned destination identity for a proxied connection.
180struct SecretHostIdentity<'a> {
181    guest_ip: IpAddr,
182    shared: &'a SharedState,
183}
184
185/// Parsed HTTP/1 request metadata needed for validation and framing.
186struct HttpRequestMetadata {
187    host_headers: Vec<String>,
188}
189
190/// HTTP request framing decision for a complete header block.
191struct RequestFraming {
192    state: HttpState,
193    body_in_request: usize,
194    body_substitution_allowed: bool,
195}
196
197/// Output from processing one chunked-body plaintext fragment.
198struct ChunkedRewriteResult {
199    output: Vec<u8>,
200    body_end: Option<usize>,
201}
202
203/// Event emitted by the chunked transfer parser.
204enum ChunkedBodyEvent<'a> {
205    Payload(&'a [u8]),
206    ZeroChunk,
207    TrailerLine(&'a [u8]),
208}
209
210/// A secret that passed host matching for this connection.
211struct EligibleSecret {
212    placeholder: String,
213    value: String,
214    inject_headers: bool,
215    inject_basic_auth: bool,
216    inject_query_params: bool,
217    inject_body: bool,
218    require_tls_identity: bool,
219}
220
221/// A secret that did not pass substitution or passthrough host matching.
222struct IneligibleSecret {
223    placeholder: String,
224    action: BlockingAction,
225}
226
227/// Blocking action to take when an ineligible placeholder is detected.
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
229enum BlockingAction {
230    Block,
231    #[default]
232    BlockAndLog,
233    BlockAndTerminate,
234}
235
236//--------------------------------------------------------------------------------------------------
237// Methods
238//--------------------------------------------------------------------------------------------------
239
240impl EligibleSecret {
241    /// Returns true if any of the header-side injection scopes is enabled
242    /// (`headers`, `basic_auth`, or `query_params`).
243    fn wants_header_injection(&self) -> bool {
244        self.inject_headers || self.inject_basic_auth || self.inject_query_params
245    }
246
247    /// Returns true when the current header bytes contain this secret's
248    /// placeholder in a header-substitution scope.
249    fn may_substitute_in_headers(&self, headers: &[u8]) -> bool {
250        if !self.wants_header_injection() {
251            return false;
252        }
253
254        let needle = self.placeholder.as_bytes();
255        if (self.inject_headers || self.inject_query_params) && contains_bytes(headers, needle) {
256            return true;
257        }
258
259        // Search decoded Basic auth credentials, not the raw header value.
260        if self.inject_basic_auth {
261            return basic_auth_decoded_contains(
262                String::from_utf8_lossy(headers).as_ref(),
263                &self.placeholder,
264            );
265        }
266
267        false
268    }
269
270    /// Substitute this secret's placeholder in the headers portion, scoped by
271    /// the secret's `headers` / `basic_auth` / `query_params` flags.
272    fn substitute_in_headers(&self, headers: &str) -> String {
273        let mut result = String::with_capacity(headers.len());
274        for (i, line) in headers.split("\r\n").enumerate() {
275            if i > 0 {
276                result.push_str("\r\n");
277            }
278            match self.substitute_in_header_line(line, i == 0) {
279                Some(s) => result.push_str(&s),
280                None => result.push_str(line),
281            }
282        }
283        result
284    }
285
286    /// Substitute this secret's placeholder in a single header line. Returns
287    /// `None` if the line is not in scope for any of the requested injection
288    /// modes.
289    fn substitute_in_header_line(&self, line: &str, is_request_line: bool) -> Option<String> {
290        if is_request_line {
291            return self
292                .inject_query_params
293                .then(|| substitute_query_in_request_line(line, &self.placeholder, &self.value))
294                .flatten();
295        }
296
297        if self.inject_basic_auth
298            && is_authorization_header(line)
299            && let Some(replaced) = self.substitute_basic_auth_header(line)
300        {
301            return Some(replaced);
302        }
303        if self.inject_headers {
304            return Some(line.replace(&self.placeholder, &self.value));
305        }
306        None
307    }
308
309    /// Decode `Basic <base64>` credentials, substitute the placeholder in the
310    /// decoded `user:password`, and return the re-encoded line. Returns `None`
311    /// if the line isn't `Basic` scheme or the decoded credentials don't
312    /// contain the placeholder. Non-Basic schemes (e.g. `Bearer`) are handled
313    /// by `inject_headers` instead.
314    fn substitute_basic_auth_header(&self, line: &str) -> Option<String> {
315        let decoded = decode_basic_credentials(line)?;
316        if !decoded.contains(&self.placeholder) {
317            return None;
318        }
319        let (name, _) = line.split_once(':')?;
320        let replaced = decoded.replace(&self.placeholder, &self.value);
321        Some(format!(
322            "{name}: Basic {}",
323            BASE64.encode(replaced.as_bytes())
324        ))
325    }
326}
327
328impl BlockingAction {
329    fn from_violation_action(action: &ViolationAction) -> Option<Self> {
330        match action {
331            ViolationAction::Block => Some(Self::Block),
332            ViolationAction::BlockAndLog => Some(Self::BlockAndLog),
333            ViolationAction::BlockAndTerminate => Some(Self::BlockAndTerminate),
334            ViolationAction::Passthrough(_) => None,
335        }
336    }
337
338    fn into_violation_action(self) -> ViolationAction {
339        match self {
340            Self::Block => ViolationAction::Block,
341            Self::BlockAndLog => ViolationAction::BlockAndLog,
342            Self::BlockAndTerminate => ViolationAction::BlockAndTerminate,
343        }
344    }
345}
346
347impl Default for Http2State {
348    fn default() -> Self {
349        Self {
350            preface_seen: false,
351            buffer: Vec::new(),
352            header_block: None,
353            open_request_streams: HashSet::new(),
354            data_tails: HashMap::new(),
355            decoder: HpackDecoder::with_dynamic_size(4096),
356            encoder: HpackEncoder::with_dynamic_size(4096),
357        }
358    }
359}
360
361impl SecretsHandler {
362    /// Create a handler for a specific connection.
363    ///
364    /// Filters secrets by host matching against the SNI. Only secrets
365    /// whose `allowed_hosts` match `sni` will be substituted.
366    /// `tls_intercepted` indicates whether this is a MITM connection
367    /// (true) or a bypass/plain connection (false).
368    pub fn new(config: &SecretsConfig, sni: &str, tls_intercepted: bool) -> Self {
369        Self::new_inner(config, sni, tls_intercepted, None)
370    }
371
372    /// Create a handler for a TLS-intercepted connection.
373    ///
374    /// Host-scoped secrets require both an SNI match and a DNS cache binding
375    /// from the original guest destination IP to the allowed host.
376    pub fn new_tls_intercepted(
377        config: &SecretsConfig,
378        sni: &str,
379        guest_ip: IpAddr,
380        shared: &SharedState,
381    ) -> Self {
382        Self::new_inner(
383            config,
384            sni,
385            true,
386            Some(SecretHostIdentity { guest_ip, shared }),
387        )
388    }
389
390    fn new_inner(
391        config: &SecretsConfig,
392        sni: &str,
393        tls_intercepted: bool,
394        identity: Option<SecretHostIdentity<'_>>,
395    ) -> Self {
396        let enforce_http_authority = identity.is_some();
397        let mut eligible_for_substitution = Vec::new();
398        let mut ineligible_for_substitution = Vec::new();
399        let mut max_detection_window_len = 0;
400        let mut max_body_placeholder_len = 0;
401        let mut placeholder_limit_exceeded = false;
402
403        for secret in &config.secrets {
404            if secret.placeholder.len() > MAX_SECRET_PLACEHOLDER_BYTES {
405                placeholder_limit_exceeded = true;
406            }
407            max_detection_window_len = max_detection_window_len.max(max_placeholder_detection_len(
408                secret.placeholder.len().min(MAX_SECRET_PLACEHOLDER_BYTES),
409            ));
410
411            let host_allowed = secret_host_allowed(secret, sni, identity.as_ref());
412
413            // If the SNI matches an allowed host for this secret, add it to the
414            // eligible list for substitution, and skip violation checks for this secret.
415            if host_allowed {
416                if secret.injection.body {
417                    max_body_placeholder_len = max_body_placeholder_len
418                        .max(secret.placeholder.len().min(MAX_SECRET_PLACEHOLDER_BYTES));
419                }
420                eligible_for_substitution.push(EligibleSecret {
421                    placeholder: secret.placeholder.clone(),
422                    value: secret.value.clone(),
423                    inject_headers: secret.injection.headers,
424                    inject_basic_auth: secret.injection.basic_auth,
425                    inject_query_params: secret.injection.query_params,
426                    inject_body: secret.injection.body,
427                    require_tls_identity: secret.require_tls_identity,
428                });
429
430                continue;
431            }
432
433            let action = effective_violation_action(secret, config, sni, identity.as_ref());
434
435            // Passthrough means the placeholder can be forwarded unchanged to this SNI.
436            if let ViolationAction::Passthrough(hosts) = action
437                && hosts
438                    .iter()
439                    .any(|p| host_pattern_allowed(p, sni, identity.as_ref()))
440            {
441                continue;
442            }
443
444            // Non-matching passthrough policies fall back to the default blocking action.
445            ineligible_for_substitution.push(IneligibleSecret {
446                placeholder: secret.placeholder.clone(),
447                action: BlockingAction::from_violation_action(action).unwrap_or_default(),
448            });
449        }
450
451        Self {
452            eligible_for_substitution,
453            ineligible_for_substitution,
454            tls_intercepted,
455            max_detection_window_len,
456            max_body_placeholder_len,
457            placeholder_limit_exceeded,
458            prev_tail: Vec::new(),
459            http_state: HttpState::AwaitingHeaders,
460            http_sni: enforce_http_authority.then(|| sni.to_string()),
461            http_pending: Vec::new(),
462            http2_state: None,
463        }
464    }
465
466    /// Substitute secrets in plaintext data (guest → server direction).
467    ///
468    /// Splits the HTTP message on `\r\n\r\n` to scope substitution:
469    /// - `headers`: substitutes in the header portion (before boundary)
470    /// - `basic_auth`: substitutes in Authorization headers specifically
471    /// - `query_params`: substitutes in the request line (first line, query portion)
472    /// - `body`: substitutes in the body portion (after boundary)
473    ///
474    /// Returns the violation action if a placeholder is detected going to a
475    /// disallowed host.
476    pub fn substitute<'a>(&mut self, data: &'a [u8]) -> Result<Cow<'a, [u8]>, ViolationAction> {
477        if self.placeholder_limit_exceeded {
478            tracing::error!(
479                "secret configuration rejected: placeholder exceeds {} bytes",
480                MAX_SECRET_PLACEHOLDER_BYTES
481            );
482            return Err(ViolationAction::Block);
483        }
484
485        if self.http2_state.is_some() {
486            return self.substitute_http2(data);
487        }
488
489        if self.http_pending.is_empty() {
490            if has_complete_http2_preface(data) {
491                self.http2_state = Some(Http2State::default());
492                return self.substitute_http2(data);
493            }
494            if is_http2_preface_prefix(data) {
495                self.http_pending.extend_from_slice(data);
496                return Ok(Cow::Owned(Vec::new()));
497            }
498        } else {
499            let mut pending_prefix = Vec::with_capacity(self.http_pending.len() + data.len());
500            pending_prefix.extend_from_slice(&self.http_pending);
501            pending_prefix.extend_from_slice(data);
502            if has_complete_http2_preface(&pending_prefix) {
503                self.http_pending.clear();
504                self.http2_state = Some(Http2State::default());
505                return self.substitute_http2(&pending_prefix);
506            }
507            if is_http2_preface_prefix(&pending_prefix) {
508                self.http_pending = pending_prefix;
509                return Ok(Cow::Owned(Vec::new()));
510            }
511        }
512
513        match std::mem::replace(&mut self.http_state, HttpState::AwaitingHeaders) {
514            HttpState::BufferingBody { remaining } => {
515                return self.substitute_buffered_body(data, remaining);
516            }
517            HttpState::InBody { remaining } => {
518                return self.substitute_body_chunk(data, remaining);
519            }
520            HttpState::InChunkedBody { state } => {
521                return self.substitute_chunked_body_chunk(data, state);
522            }
523            HttpState::InChunkedRewriteBody { state } => {
524                return self.substitute_chunked_rewrite_body_chunk(data, state);
525            }
526            HttpState::AwaitingHeaders => {}
527        }
528
529        if !self.http_pending.is_empty() {
530            self.http_pending.extend_from_slice(data);
531            if self.http_pending.len() > MAX_HTTP_HEADER_BYTES {
532                return Err(ViolationAction::Block);
533            }
534            if find_header_boundary(&self.http_pending).is_none() {
535                if first_line_is_not_http_request(&self.http_pending)
536                    || !looks_like_http_request_prefix(&self.http_pending)
537                {
538                    let pending = std::mem::take(&mut self.http_pending);
539                    let output = self.substitute_ready(&pending)?.into_owned();
540                    return Ok(Cow::Owned(output));
541                }
542                return Ok(Cow::Owned(Vec::new()));
543            }
544
545            let pending = std::mem::take(&mut self.http_pending);
546            let output = self.substitute_ready(&pending)?.into_owned();
547            return Ok(Cow::Owned(output));
548        }
549
550        if find_header_boundary(data).is_none()
551            && looks_like_http_request_prefix(data)
552            && !first_line_is_not_http_request(data)
553        {
554            if data.len() > MAX_HTTP_HEADER_BYTES {
555                return Err(ViolationAction::Block);
556            }
557            self.http_pending.extend_from_slice(data);
558            return Ok(Cow::Owned(Vec::new()));
559        }
560
561        self.substitute_ready(data)
562    }
563
564    fn substitute_http2<'a>(&mut self, data: &[u8]) -> Result<Cow<'a, [u8]>, ViolationAction> {
565        let mut state = self.http2_state.take().unwrap_or_default();
566        let output = state.process(self, data)?;
567        self.http2_state = Some(state);
568        Ok(Cow::Owned(output))
569    }
570
571    fn substitute_ready<'a>(&mut self, data: &'a [u8]) -> Result<Cow<'a, [u8]>, ViolationAction> {
572        // Split raw bytes at the header boundary BEFORE converting to owned strings.
573        // This avoids position shifts from from_utf8_lossy replacement chars.
574        let boundary = find_header_boundary(data);
575        let (header_bytes, after_headers) = match boundary {
576            Some(pos) => (&data[..pos], &data[pos..]),
577            None => (data, &[] as &[u8]),
578        };
579
580        // A single chunk may carry headers + body + the start of the next
581        // pipelined request. Compute how many post-boundary bytes belong to
582        // THIS request; the rest is spillover that gets its own recursive
583        // pass through `substitute()` so its headers are substituted and
584        // its violations are detected.
585        let mut body_substitution_allowed = false;
586        let (body_bytes, spillover) = if boundary.is_some() {
587            let header_text = String::from_utf8_lossy(header_bytes);
588            if let Some(sni) = self.http_sni.as_deref()
589                && let Some(metadata) = parse_http_request_metadata(header_bytes)?
590                && !metadata
591                    .host_headers
592                    .iter()
593                    .all(|host| authority_matches_sni(host, sni))
594            {
595                return Err(ViolationAction::Block);
596            }
597
598            if is_transfer_chunked(header_text.as_ref()) {
599                return self.substitute_chunked_ready(
600                    data,
601                    header_bytes,
602                    after_headers,
603                    header_text.as_ref(),
604                );
605            }
606
607            let framing = next_state_after_headers(header_text.as_ref(), after_headers)?;
608            if self.needs_body_injection()
609                && framing.body_substitution_allowed
610                && content_length_exceeds_buffer_limit(header_text.as_ref())?
611            {
612                return Err(ViolationAction::Block);
613            }
614            if self.needs_body_injection()
615                && framing.body_substitution_allowed
616                && let HttpState::InBody { remaining } = &framing.state
617            {
618                self.http_pending.extend_from_slice(data);
619                self.http_state = HttpState::BufferingBody {
620                    remaining: *remaining,
621                };
622                return Ok(Cow::Owned(Vec::new()));
623            }
624
625            body_substitution_allowed = framing.body_substitution_allowed;
626            self.http_state = framing.state;
627            after_headers.split_at(framing.body_in_request)
628        } else {
629            (after_headers, &[] as &[u8])
630        };
631
632        // Everything from `data` belonging to this request, headers and body.
633        let this_request = &data[..header_bytes.len() + body_bytes.len()];
634
635        // Check for disallowed placeholders before forwarding or substituting data.
636        if let Some(action) = self
637            .detect_blocking_action(this_request, String::from_utf8_lossy(header_bytes).as_ref())
638        {
639            match action {
640                BlockingAction::Block => return Err(action.into_violation_action()),
641                BlockingAction::BlockAndLog => {
642                    tracing::warn!("secret violation: placeholder detected for disallowed host");
643                    return Err(action.into_violation_action());
644                }
645                BlockingAction::BlockAndTerminate => {
646                    tracing::error!(
647                        "secret violation: placeholder detected for disallowed host - terminating"
648                    );
649                    return Err(action.into_violation_action());
650                }
651            }
652        }
653        self.update_tail(this_request);
654
655        if self.eligible_for_substitution.is_empty() {
656            // No substitution needed; pass this request through and let the
657            // recursive call handle the spillover (if any).
658            return self.append_pipelined_spillover(data, this_request, spillover);
659        }
660
661        // Start with borrowed bytes; allocate only when a substitution is needed.
662        let mut header_str = None;
663        let mut body = None;
664
665        for secret in &self.eligible_for_substitution {
666            // Skip secrets that require TLS identity on non-intercepted connections.
667            if secret.require_tls_identity && !self.tls_intercepted {
668                continue;
669            }
670
671            // Header substitution still uses string helpers after a scoped match.
672            if secret.may_substitute_in_headers(header_bytes) {
673                let current = header_str
674                    .get_or_insert_with(|| String::from_utf8_lossy(header_bytes).into_owned());
675                *current = secret.substitute_in_headers(current);
676            }
677
678            // Body substitution works on bytes so encoded payloads stay valid.
679            if body_substitution_allowed && secret.inject_body {
680                let source = body.as_deref().unwrap_or(body_bytes);
681                if let Some(replaced) = replace_bytes(
682                    source,
683                    secret.placeholder.as_bytes(),
684                    secret.value.as_bytes(),
685                ) {
686                    body = Some(replaced);
687                }
688            }
689        }
690
691        let header_changed = header_str
692            .as_ref()
693            .is_some_and(|headers| headers.as_bytes() != header_bytes);
694        let body_changed = body.is_some();
695
696        // No header or body replacement was produced. Forward this request
697        // unchanged and recurse on the spillover.
698        if !header_changed && !body_changed {
699            return self.append_pipelined_spillover(data, this_request, spillover);
700        }
701
702        let header_len = header_str
703            .as_ref()
704            .map_or(header_bytes.len(), |headers| headers.len());
705        let body_len = body.as_ref().map_or(body_bytes.len(), Vec::len);
706        let mut output = Vec::with_capacity(header_len + body_len + spillover.len());
707
708        let body_bytes_out = body.as_deref().unwrap_or(body_bytes);
709        // Update Content-Length only when body substitution changed the size.
710        if body_changed && body_bytes_out.len() != body_bytes.len() {
711            let headers = match header_str {
712                Some(headers) => update_content_length(&headers, body_bytes_out.len()),
713                None => update_content_length(
714                    String::from_utf8_lossy(header_bytes).as_ref(),
715                    body_bytes_out.len(),
716                ),
717            };
718            output.extend_from_slice(headers.as_bytes());
719        } else if let Some(headers) = header_str {
720            output.extend_from_slice(headers.as_bytes());
721        } else {
722            output.extend_from_slice(header_bytes);
723        }
724
725        output.extend_from_slice(body_bytes_out);
726
727        if !spillover.is_empty() {
728            let next_out = self.substitute(spillover)?;
729            output.extend_from_slice(next_out.as_ref());
730        }
731        Ok(Cow::Owned(output))
732    }
733
734    fn substitute_buffered_body<'a>(
735        &mut self,
736        data: &'a [u8],
737        remaining: usize,
738    ) -> Result<Cow<'a, [u8]>, ViolationAction> {
739        let take = remaining.min(data.len());
740        self.http_pending.extend_from_slice(&data[..take]);
741
742        if take < remaining {
743            self.http_state = HttpState::BufferingBody {
744                remaining: remaining - take,
745            };
746            return Ok(Cow::Owned(Vec::new()));
747        }
748
749        self.http_state = HttpState::AwaitingHeaders;
750        let request = std::mem::take(&mut self.http_pending);
751        let mut output = self.substitute_ready(&request)?.into_owned();
752
753        if data.len() > take {
754            let spillover = self.substitute(&data[take..])?;
755            output.extend_from_slice(spillover.as_ref());
756        }
757
758        Ok(Cow::Owned(output))
759    }
760
761    /// Forward `this_request` (an unchanged subslice of `parent`) and
762    /// recursively `substitute()` the `spillover` (the start of a
763    /// pipelined next request). When both halves pass through unchanged,
764    /// returns `Cow::Borrowed(parent)` for zero-copy.
765    fn append_pipelined_spillover<'a>(
766        &mut self,
767        parent: &'a [u8],
768        this_request: &'a [u8],
769        spillover: &'a [u8],
770    ) -> Result<Cow<'a, [u8]>, ViolationAction> {
771        if spillover.is_empty() {
772            return Ok(Cow::Borrowed(parent));
773        }
774        let next_out = self.substitute(spillover)?;
775        if let Cow::Borrowed(b) = &next_out
776            && std::ptr::eq(b.as_ptr(), spillover.as_ptr())
777            && b.len() == spillover.len()
778        {
779            // Spillover passed through unchanged; both halves are contiguous
780            // subslices of `parent`, so the whole parent can be returned
781            // borrowed.
782            return Ok(Cow::Borrowed(parent));
783        }
784        let next_bytes = next_out.as_ref();
785        let mut out = Vec::with_capacity(this_request.len() + next_bytes.len());
786        out.extend_from_slice(this_request);
787        out.extend_from_slice(next_bytes);
788        Ok(Cow::Owned(out))
789    }
790
791    /// Handle a chunked request whose headers are complete in `parent`.
792    fn substitute_chunked_ready<'a>(
793        &mut self,
794        parent: &'a [u8],
795        header_bytes: &'a [u8],
796        after_headers: &'a [u8],
797        headers: &str,
798    ) -> Result<Cow<'a, [u8]>, ViolationAction> {
799        if self.needs_body_injection() && !has_non_identity_content_encoding(headers) {
800            return self.substitute_chunked_rewrite_ready(
801                parent,
802                header_bytes,
803                after_headers,
804                headers,
805            );
806        }
807
808        let mut state = ChunkedBodyState::default();
809        let body_end =
810            self.consume_chunked_body_with_violation_detection(&mut state, after_headers)?;
811        let (body_part, spillover) = match body_end {
812            Some(end) => after_headers.split_at(end),
813            None => (after_headers, &[] as &[u8]),
814        };
815        let this_request = &parent[..header_bytes.len() + body_part.len()];
816
817        self.apply_blocking_action(self.detect_blocking_action(this_request, headers))?;
818        self.update_tail(this_request);
819
820        self.http_state = if body_end.is_some() {
821            HttpState::AwaitingHeaders
822        } else {
823            HttpState::InChunkedBody { state }
824        };
825
826        if let Some(headers) = self.substitute_header_bytes(header_bytes) {
827            let mut output = Vec::with_capacity(headers.len() + body_part.len() + spillover.len());
828            output.extend_from_slice(headers.as_bytes());
829            output.extend_from_slice(body_part);
830            if !spillover.is_empty() {
831                let next_out = self.substitute(spillover)?;
832                output.extend_from_slice(next_out.as_ref());
833            }
834            return Ok(Cow::Owned(output));
835        }
836
837        self.append_pipelined_spillover(parent, this_request, spillover)
838    }
839
840    /// Handle a chunked request that needs body substitution.
841    fn substitute_chunked_rewrite_ready<'a>(
842        &mut self,
843        parent: &'a [u8],
844        header_bytes: &'a [u8],
845        after_headers: &'a [u8],
846        headers: &str,
847    ) -> Result<Cow<'a, [u8]>, ViolationAction> {
848        let mut state = ChunkedRewriteState::default();
849        let rewrite = self.rewrite_chunked_body_part(&mut state, after_headers)?;
850        let (body_part, spillover) = match rewrite.body_end {
851            Some(end) => after_headers.split_at(end),
852            None => (after_headers, &[] as &[u8]),
853        };
854        let this_request = &parent[..header_bytes.len() + body_part.len()];
855
856        self.apply_blocking_action(self.detect_blocking_action(this_request, headers))?;
857        self.update_tail(this_request);
858
859        self.http_state = if rewrite.body_end.is_some() {
860            HttpState::AwaitingHeaders
861        } else {
862            HttpState::InChunkedRewriteBody { state }
863        };
864
865        let header_len = header_bytes.len();
866        let header_out = self.substitute_header_bytes(header_bytes);
867        let mut output = Vec::with_capacity(
868            header_out
869                .as_ref()
870                .map_or(header_len, |headers| headers.len())
871                + rewrite.output.len()
872                + spillover.len(),
873        );
874        if let Some(headers) = header_out {
875            output.extend_from_slice(headers.as_bytes());
876        } else {
877            output.extend_from_slice(header_bytes);
878        }
879        output.extend_from_slice(&rewrite.output);
880
881        if !spillover.is_empty() {
882            let next_out = self.substitute(spillover)?;
883            output.extend_from_slice(next_out.as_ref());
884        }
885
886        Ok(Cow::Owned(output))
887    }
888
889    /// Handle a chunk that is the continuation of the current request's
890    /// body (no headers present at the start). The body bytes are
891    /// forwarded as-is after a violation scan. If the body ends inside
892    /// this chunk and the remaining bytes are a pipelined next request,
893    /// they are recursively dispatched through `substitute()` so their
894    /// headers are substituted and their violations are detected.
895    ///
896    /// Body substitution across chunks is unsupported (would require
897    /// rewriting Content-Length in already-forwarded headers).
898    fn substitute_body_chunk<'a>(
899        &mut self,
900        data: &'a [u8],
901        remaining: usize,
902    ) -> Result<Cow<'a, [u8]>, ViolationAction> {
903        // Determine where this request's body ends inside the chunk.
904        //
905        // Content-Length framing splits at `remaining`. Trailing bytes are a
906        // pipelined next request.
907        let body_end = (data.len() >= remaining).then_some(remaining);
908        let (body_part, spillover) = match body_end {
909            Some(end) => data.split_at(end),
910            None => (data, &[] as &[u8]),
911        };
912
913        if let Some(action) = self.detect_blocking_action(body_part, "") {
914            match action {
915                BlockingAction::Block => return Err(action.into_violation_action()),
916                BlockingAction::BlockAndLog => {
917                    tracing::warn!("secret violation: placeholder detected for disallowed host");
918                    return Err(action.into_violation_action());
919                }
920                BlockingAction::BlockAndTerminate => {
921                    tracing::error!(
922                        "secret violation: placeholder detected for disallowed host - terminating"
923                    );
924                    return Err(action.into_violation_action());
925                }
926            }
927        }
928        self.update_tail(body_part);
929
930        // Advance framing state. If the body completes within this chunk,
931        // the spillover below is the start of a fresh request.
932        self.http_state = match body_end {
933            Some(_) => HttpState::AwaitingHeaders,
934            None => HttpState::InBody {
935                remaining: remaining - body_part.len(),
936            },
937        };
938
939        self.append_pipelined_spillover(data, body_part, spillover)
940    }
941
942    /// Handle continuation bytes for a chunked request body.
943    fn substitute_chunked_body_chunk<'a>(
944        &mut self,
945        data: &'a [u8],
946        mut state: ChunkedBodyState,
947    ) -> Result<Cow<'a, [u8]>, ViolationAction> {
948        let body_end = self.consume_chunked_body_with_violation_detection(&mut state, data)?;
949        let (body_part, spillover) = match body_end {
950            Some(end) => data.split_at(end),
951            None => (data, &[] as &[u8]),
952        };
953
954        if let Some(action) = self.detect_blocking_action(body_part, "") {
955            match action {
956                BlockingAction::Block => return Err(action.into_violation_action()),
957                BlockingAction::BlockAndLog => {
958                    tracing::warn!("secret violation: placeholder detected for disallowed host");
959                    return Err(action.into_violation_action());
960                }
961                BlockingAction::BlockAndTerminate => {
962                    tracing::error!(
963                        "secret violation: placeholder detected for disallowed host - terminating"
964                    );
965                    return Err(action.into_violation_action());
966                }
967            }
968        }
969        self.update_tail(body_part);
970
971        self.http_state = if body_end.is_some() {
972            HttpState::AwaitingHeaders
973        } else {
974            HttpState::InChunkedBody { state }
975        };
976
977        self.append_pipelined_spillover(data, body_part, spillover)
978    }
979
980    /// Handle continuation bytes for a chunked request body that is being
981    /// decoded and re-encoded for body substitution.
982    fn substitute_chunked_rewrite_body_chunk<'a>(
983        &mut self,
984        data: &'a [u8],
985        mut state: ChunkedRewriteState,
986    ) -> Result<Cow<'a, [u8]>, ViolationAction> {
987        let rewrite = self.rewrite_chunked_body_part(&mut state, data)?;
988        let (body_part, spillover) = match rewrite.body_end {
989            Some(end) => data.split_at(end),
990            None => (data, &[] as &[u8]),
991        };
992
993        self.apply_blocking_action(self.detect_blocking_action(body_part, ""))?;
994        self.update_tail(body_part);
995
996        self.http_state = if rewrite.body_end.is_some() {
997            HttpState::AwaitingHeaders
998        } else {
999            HttpState::InChunkedRewriteBody { state }
1000        };
1001
1002        let mut output = rewrite.output;
1003        if !spillover.is_empty() {
1004            let next_out = self.substitute(spillover)?;
1005            output.extend_from_slice(next_out.as_ref());
1006        }
1007
1008        Ok(Cow::Owned(output))
1009    }
1010
1011    /// Returns true if this connection needs no secret substitution or violation detection.
1012    pub fn is_empty(&self) -> bool {
1013        self.http_sni.is_none()
1014            && self.http_pending.is_empty()
1015            && self.http2_state.is_none()
1016            && matches!(self.http_state, HttpState::AwaitingHeaders)
1017            && self.eligible_for_substitution.is_empty()
1018            && self.ineligible_for_substitution.is_empty()
1019    }
1020
1021    fn needs_body_injection(&self) -> bool {
1022        self.eligible_for_substitution.iter().any(|secret| {
1023            secret.inject_body && (!secret.require_tls_identity || self.tls_intercepted)
1024        })
1025    }
1026
1027    fn contains_eligible_http2_body_placeholder(&self, prev_tail: &[u8], data: &[u8]) -> bool {
1028        if !self.needs_body_injection() {
1029            return false;
1030        }
1031
1032        let scan_buf: Cow<[u8]> = if prev_tail.is_empty() {
1033            Cow::Borrowed(data)
1034        } else {
1035            let mut stitched = Vec::with_capacity(prev_tail.len() + data.len());
1036            stitched.extend_from_slice(prev_tail);
1037            stitched.extend_from_slice(data);
1038            Cow::Owned(stitched)
1039        };
1040        let scan = scan_buf.as_ref();
1041        self.eligible_for_substitution.iter().any(|secret| {
1042            secret.inject_body
1043                && !secret.placeholder.is_empty()
1044                && (!secret.require_tls_identity || self.tls_intercepted)
1045                && contains_bytes(scan, secret.placeholder.as_bytes())
1046        })
1047    }
1048
1049    fn substitute_http2_headers(&self, headers: &mut [(Vec<u8>, Vec<u8>)]) {
1050        for secret in &self.eligible_for_substitution {
1051            if secret.require_tls_identity && !self.tls_intercepted {
1052                continue;
1053            }
1054
1055            for (name, value) in headers.iter_mut() {
1056                let is_pseudo = name.starts_with(b":");
1057
1058                if name.eq_ignore_ascii_case(b":path")
1059                    && secret.inject_query_params
1060                    && let Ok(path) = std::str::from_utf8(value)
1061                    && let Some(replaced) =
1062                        substitute_query_in_target(path, &secret.placeholder, &secret.value)
1063                {
1064                    *value = replaced.into_bytes();
1065                }
1066
1067                if !is_pseudo
1068                    && name.eq_ignore_ascii_case(b"authorization")
1069                    && secret.inject_basic_auth
1070                    && let Ok(header_value) = std::str::from_utf8(value)
1071                    && let Some(replaced) = substitute_basic_auth_value(
1072                        header_value,
1073                        &secret.placeholder,
1074                        &secret.value,
1075                    )
1076                {
1077                    *value = replaced.into_bytes();
1078                }
1079
1080                if !is_pseudo
1081                    && secret.inject_headers
1082                    && contains_bytes(value, secret.placeholder.as_bytes())
1083                {
1084                    let replaced =
1085                        String::from_utf8_lossy(value).replace(&secret.placeholder, &secret.value);
1086                    *value = replaced.into_bytes();
1087                }
1088            }
1089        }
1090    }
1091
1092    fn substitute_header_bytes(&self, header_bytes: &[u8]) -> Option<String> {
1093        let mut header_str: Option<String> = None;
1094        for secret in &self.eligible_for_substitution {
1095            if secret.require_tls_identity && !self.tls_intercepted {
1096                continue;
1097            }
1098            if secret.may_substitute_in_headers(header_bytes) {
1099                let current = header_str
1100                    .get_or_insert_with(|| String::from_utf8_lossy(header_bytes).into_owned());
1101                *current = secret.substitute_in_headers(current);
1102            }
1103        }
1104
1105        header_str.filter(|headers| headers.as_bytes() != header_bytes)
1106    }
1107
1108    fn consume_chunked_body_with_violation_detection(
1109        &self,
1110        state: &mut ChunkedBodyState,
1111        data: &[u8],
1112    ) -> Result<Option<usize>, ViolationAction> {
1113        let mut decoded_tail = std::mem::take(&mut state.decoded_tail);
1114        let body_end = process_chunked_body(state, data, |event| {
1115            let ChunkedBodyEvent::Payload(payload) = event else {
1116                return Ok(());
1117            };
1118            self.apply_blocking_action(detect_blocking_action_with_tail(
1119                &self.ineligible_for_substitution,
1120                &decoded_tail,
1121                payload,
1122                "",
1123            ))?;
1124            update_tail_buffer(
1125                &mut decoded_tail,
1126                payload,
1127                self.max_detection_window_len.saturating_sub(1),
1128            );
1129            Ok(())
1130        });
1131        state.decoded_tail = decoded_tail;
1132        body_end
1133    }
1134
1135    fn rewrite_chunked_body_part(
1136        &self,
1137        state: &mut ChunkedRewriteState,
1138        data: &[u8],
1139    ) -> Result<ChunkedRewriteResult, ViolationAction> {
1140        let mut output = Vec::new();
1141        let mut decoded_tail = std::mem::take(&mut state.parser.decoded_tail);
1142        let mut substitution_tail = std::mem::take(&mut state.substitution_tail);
1143
1144        let body_end = process_chunked_body(&mut state.parser, data, |event| {
1145            match event {
1146                ChunkedBodyEvent::Payload(payload) => {
1147                    self.apply_blocking_action(detect_blocking_action_with_tail(
1148                        &self.ineligible_for_substitution,
1149                        &decoded_tail,
1150                        payload,
1151                        "",
1152                    ))?;
1153                    update_tail_buffer(
1154                        &mut decoded_tail,
1155                        payload,
1156                        self.max_detection_window_len.saturating_sub(1),
1157                    );
1158                    self.append_rewritten_chunked_payload(
1159                        &mut substitution_tail,
1160                        payload,
1161                        &mut output,
1162                    );
1163                }
1164                ChunkedBodyEvent::ZeroChunk => {
1165                    self.flush_rewritten_chunked_payload(&mut substitution_tail, &mut output);
1166                    output.extend_from_slice(b"0\r\n");
1167                }
1168                ChunkedBodyEvent::TrailerLine(trailer_line) => {
1169                    output.extend_from_slice(trailer_line);
1170                }
1171            }
1172            Ok(())
1173        })?;
1174
1175        state.parser.decoded_tail = decoded_tail;
1176        state.substitution_tail = substitution_tail;
1177
1178        Ok(ChunkedRewriteResult { output, body_end })
1179    }
1180
1181    fn append_rewritten_chunked_payload(
1182        &self,
1183        substitution_tail: &mut Vec<u8>,
1184        payload: &[u8],
1185        output: &mut Vec<u8>,
1186    ) {
1187        substitution_tail.extend_from_slice(payload);
1188        let carry_len = self.max_body_placeholder_len.saturating_sub(1);
1189        self.append_rewritten_chunked_prefix(substitution_tail, carry_len, output);
1190    }
1191
1192    fn flush_rewritten_chunked_payload(
1193        &self,
1194        substitution_tail: &mut Vec<u8>,
1195        output: &mut Vec<u8>,
1196    ) {
1197        self.append_rewritten_chunked_prefix(substitution_tail, 0, output);
1198    }
1199
1200    fn append_rewritten_chunked_prefix(
1201        &self,
1202        substitution_tail: &mut Vec<u8>,
1203        keep_len: usize,
1204        output: &mut Vec<u8>,
1205    ) {
1206        let safe_len = substitution_tail.len().saturating_sub(keep_len);
1207        if safe_len == 0 {
1208            return;
1209        }
1210
1211        let mut cursor = 0;
1212        let mut chunk_payload = Vec::with_capacity(safe_len);
1213        while cursor < safe_len {
1214            if let Some(secret) = self.matching_body_secret_at(&substitution_tail[cursor..]) {
1215                chunk_payload.extend_from_slice(secret.value.as_bytes());
1216                cursor += secret.placeholder.len();
1217            } else {
1218                chunk_payload.push(substitution_tail[cursor]);
1219                cursor += 1;
1220            }
1221        }
1222
1223        let kept = substitution_tail.split_off(cursor);
1224        *substitution_tail = kept;
1225        append_chunk(output, &chunk_payload);
1226    }
1227
1228    fn matching_body_secret_at(&self, data: &[u8]) -> Option<&EligibleSecret> {
1229        self.eligible_for_substitution.iter().find(|secret| {
1230            secret.inject_body
1231                && !secret.placeholder.is_empty()
1232                && (!secret.require_tls_identity || self.tls_intercepted)
1233                && data.starts_with(secret.placeholder.as_bytes())
1234        })
1235    }
1236
1237    fn apply_blocking_action(&self, action: Option<BlockingAction>) -> Result<(), ViolationAction> {
1238        match action {
1239            Some(BlockingAction::Block) => Err(ViolationAction::Block),
1240            Some(BlockingAction::BlockAndLog) => {
1241                tracing::warn!("secret violation: placeholder detected for disallowed host");
1242                Err(ViolationAction::BlockAndLog)
1243            }
1244            Some(BlockingAction::BlockAndTerminate) => {
1245                tracing::error!(
1246                    "secret violation: placeholder detected for disallowed host - terminating"
1247                );
1248                Err(ViolationAction::BlockAndTerminate)
1249            }
1250            None => Ok(()),
1251        }
1252    }
1253
1254    /// Returns the strongest blocking action for any placeholder appearing in data
1255    /// for a host that isn't allowed to receive either the real secret or the placeholder.
1256    ///
1257    /// Scans the raw bytes (stitched with the previous call's tail for
1258    /// cross-write detection), plus URL- and JSON-decoded variants for
1259    /// encoded-placeholder bypass attempts, plus base64-decoded Basic auth
1260    /// credentials.
1261    fn detect_blocking_action(&self, data: &[u8], headers: &str) -> Option<BlockingAction> {
1262        detect_blocking_action_with_tail(
1263            &self.ineligible_for_substitution,
1264            &self.prev_tail,
1265            data,
1266            headers,
1267        )
1268    }
1269
1270    /// Update the sliding-window tail with the trailing bytes of `data`, so
1271    /// the next `substitute` call can detect placeholders split across the
1272    /// boundary.
1273    fn update_tail(&mut self, data: &[u8]) {
1274        update_tail_buffer(
1275            &mut self.prev_tail,
1276            data,
1277            self.max_detection_window_len.saturating_sub(1),
1278        );
1279    }
1280}
1281
1282impl Http2State {
1283    fn process(
1284        &mut self,
1285        handler: &mut SecretsHandler,
1286        data: &[u8],
1287    ) -> Result<Vec<u8>, ViolationAction> {
1288        self.buffer.extend_from_slice(data);
1289        let mut output = Vec::new();
1290
1291        if !self.preface_seen {
1292            if self.buffer.len() < HTTP2_PREFACE.len() {
1293                return Ok(output);
1294            }
1295            if !self.buffer.starts_with(HTTP2_PREFACE) {
1296                return Err(ViolationAction::Block);
1297            }
1298            output.extend_from_slice(HTTP2_PREFACE);
1299            self.buffer.drain(..HTTP2_PREFACE.len());
1300            self.preface_seen = true;
1301        }
1302
1303        loop {
1304            if self.buffer.len() < 9 {
1305                break;
1306            }
1307
1308            let frame_len = http2_frame_payload_len(&self.buffer[..9]);
1309            if frame_len > MAX_HTTP2_FRAME_PAYLOAD_BYTES {
1310                return Err(ViolationAction::Block);
1311            }
1312            let full_len = 9 + frame_len;
1313            if self.buffer.len() < full_len {
1314                break;
1315            }
1316
1317            let frame = self.buffer[..full_len].to_vec();
1318            self.buffer.drain(..full_len);
1319            self.process_frame(handler, &frame, &mut output)?;
1320        }
1321
1322        Ok(output)
1323    }
1324
1325    fn process_frame(
1326        &mut self,
1327        handler: &mut SecretsHandler,
1328        raw: &[u8],
1329        output: &mut Vec<u8>,
1330    ) -> Result<(), ViolationAction> {
1331        let frame = parse_http2_frame(raw)?;
1332
1333        if self.header_block.is_some() && frame.kind != HTTP2_FRAME_CONTINUATION {
1334            return Err(ViolationAction::Block);
1335        }
1336
1337        match frame.kind {
1338            HTTP2_FRAME_HEADERS => self.process_headers_frame(handler, frame, output),
1339            HTTP2_FRAME_CONTINUATION => self.process_continuation_frame(handler, frame, output),
1340            HTTP2_FRAME_DATA => self.process_data_frame(handler, frame, output),
1341            HTTP2_FRAME_PUSH_PROMISE => Err(ViolationAction::Block),
1342            _ => {
1343                output.extend_from_slice(frame.raw);
1344                Ok(())
1345            }
1346        }
1347    }
1348
1349    fn process_headers_frame(
1350        &mut self,
1351        handler: &mut SecretsHandler,
1352        frame: Http2Frame<'_>,
1353        output: &mut Vec<u8>,
1354    ) -> Result<(), ViolationAction> {
1355        if frame.stream_id == 0 || frame.stream_id.is_multiple_of(2) || self.header_block.is_some()
1356        {
1357            return Err(ViolationAction::Block);
1358        }
1359
1360        let fragment = http2_headers_fragment(frame.flags, frame.payload)?;
1361        if fragment.len() > MAX_HTTP2_HEADER_BLOCK_BYTES {
1362            return Err(ViolationAction::Block);
1363        }
1364
1365        let block = Http2HeaderBlock {
1366            stream_id: frame.stream_id,
1367            end_stream: frame.flags & HTTP2_FLAG_END_STREAM != 0,
1368            block: fragment.to_vec(),
1369        };
1370
1371        if frame.flags & HTTP2_FLAG_END_HEADERS != 0 {
1372            self.finish_header_block(handler, block, output)
1373        } else {
1374            self.header_block = Some(block);
1375            Ok(())
1376        }
1377    }
1378
1379    fn process_continuation_frame(
1380        &mut self,
1381        handler: &mut SecretsHandler,
1382        frame: Http2Frame<'_>,
1383        output: &mut Vec<u8>,
1384    ) -> Result<(), ViolationAction> {
1385        let Some(mut block) = self.header_block.take() else {
1386            return Err(ViolationAction::Block);
1387        };
1388        if frame.stream_id == 0 || frame.stream_id != block.stream_id {
1389            return Err(ViolationAction::Block);
1390        }
1391
1392        block.block.extend_from_slice(frame.payload);
1393        if block.block.len() > MAX_HTTP2_HEADER_BLOCK_BYTES {
1394            return Err(ViolationAction::Block);
1395        }
1396
1397        if frame.flags & HTTP2_FLAG_END_HEADERS != 0 {
1398            self.finish_header_block(handler, block, output)
1399        } else {
1400            self.header_block = Some(block);
1401            Ok(())
1402        }
1403    }
1404
1405    fn process_data_frame(
1406        &mut self,
1407        handler: &mut SecretsHandler,
1408        frame: Http2Frame<'_>,
1409        output: &mut Vec<u8>,
1410    ) -> Result<(), ViolationAction> {
1411        if frame.stream_id == 0 || !self.open_request_streams.contains(&frame.stream_id) {
1412            return Err(ViolationAction::Block);
1413        }
1414
1415        let data = http2_data_payload(frame.flags, frame.payload)?;
1416        let tail = self.data_tails.entry(frame.stream_id).or_default();
1417        if handler.contains_eligible_http2_body_placeholder(tail, data) {
1418            tracing::warn!(
1419                "secret substitution in HTTP/2 DATA frames is unsupported; blocking placeholder"
1420            );
1421            return Err(ViolationAction::Block);
1422        }
1423        handler.apply_blocking_action(detect_blocking_action_with_tail(
1424            &handler.ineligible_for_substitution,
1425            tail,
1426            data,
1427            "",
1428        ))?;
1429        update_tail_buffer(
1430            tail,
1431            data,
1432            handler.max_detection_window_len.saturating_sub(1),
1433        );
1434        if frame.flags & HTTP2_FLAG_END_STREAM != 0 {
1435            self.data_tails.remove(&frame.stream_id);
1436            self.open_request_streams.remove(&frame.stream_id);
1437        }
1438        output.extend_from_slice(frame.raw);
1439        Ok(())
1440    }
1441
1442    fn finish_header_block(
1443        &mut self,
1444        handler: &mut SecretsHandler,
1445        block: Http2HeaderBlock,
1446        output: &mut Vec<u8>,
1447    ) -> Result<(), ViolationAction> {
1448        let mut headers = self.decode_headers(&block.block)?;
1449        let is_initial_request = !self.open_request_streams.contains(&block.stream_id);
1450        if is_initial_request {
1451            if self.open_request_streams.len() >= MAX_HTTP2_TRACKED_STREAMS {
1452                return Err(ViolationAction::Block);
1453            }
1454            self.open_request_streams.insert(block.stream_id);
1455        } else if !block.end_stream {
1456            return Err(ViolationAction::Block);
1457        }
1458
1459        if let Some(sni) = handler.http_sni.as_deref() {
1460            validate_http2_authority(&headers, sni, is_initial_request)?;
1461        }
1462
1463        let detection_bytes = http2_header_detection_bytes(&headers);
1464        let detection_text = String::from_utf8_lossy(&detection_bytes);
1465        handler.apply_blocking_action(detect_blocking_action_with_tail(
1466            &handler.ineligible_for_substitution,
1467            &[],
1468            &detection_bytes,
1469            detection_text.as_ref(),
1470        ))?;
1471
1472        handler.substitute_http2_headers(&mut headers);
1473        let encoded = self.encode_headers(&headers)?;
1474        append_http2_header_frames(output, block.stream_id, block.end_stream, &encoded)?;
1475        if block.end_stream {
1476            self.data_tails.remove(&block.stream_id);
1477            self.open_request_streams.remove(&block.stream_id);
1478        }
1479        Ok(())
1480    }
1481
1482    fn decode_headers(&mut self, block: &[u8]) -> Result<Http2Headers, ViolationAction> {
1483        let mut block = block.to_vec();
1484        let mut headers = Vec::new();
1485        let mut decoded_bytes = 0usize;
1486
1487        while !block.is_empty() {
1488            let before_len = block.len();
1489            let mut decoded = Vec::with_capacity(1);
1490            self.decoder
1491                .decode_exact(&mut block, &mut decoded)
1492                .map_err(|_| ViolationAction::Block)?;
1493            if decoded.is_empty() {
1494                if block.len() == before_len {
1495                    return Err(ViolationAction::Block);
1496                }
1497                continue;
1498            }
1499
1500            if headers.len() >= MAX_HTTP2_HEADER_FIELDS {
1501                return Err(ViolationAction::Block);
1502            }
1503            let (name, value, _flags) = decoded.pop().expect("decoded one header");
1504            decoded_bytes = decoded_bytes
1505                .checked_add(name.len())
1506                .and_then(|len| len.checked_add(value.len()))
1507                .and_then(|len| len.checked_add(4))
1508                .ok_or(ViolationAction::Block)?;
1509            if decoded_bytes > MAX_HTTP2_DECODED_HEADER_BYTES {
1510                return Err(ViolationAction::Block);
1511            }
1512
1513            headers.push((name, value));
1514        }
1515
1516        Ok(headers)
1517    }
1518
1519    fn encode_headers(
1520        &mut self,
1521        headers: &[(Vec<u8>, Vec<u8>)],
1522    ) -> Result<Vec<u8>, ViolationAction> {
1523        let mut encoded = Vec::new();
1524        for (name, value) in headers {
1525            self.encoder
1526                .encode(
1527                    (name.clone(), value.clone(), HpackEncoder::NEVER_INDEXED),
1528                    &mut encoded,
1529                )
1530                .map_err(|_| ViolationAction::Block)?;
1531        }
1532        Ok(encoded)
1533    }
1534}
1535
1536//--------------------------------------------------------------------------------------------------
1537// Functions
1538//--------------------------------------------------------------------------------------------------
1539
1540/// Returns true if `line` starts with the `Authorization:` header name
1541/// (case-insensitive).
1542fn is_authorization_header(line: &str) -> bool {
1543    line.as_bytes()
1544        .get(..14)
1545        .is_some_and(|b| b.eq_ignore_ascii_case(b"authorization:"))
1546}
1547
1548fn is_http2_preface_prefix(data: &[u8]) -> bool {
1549    !data.is_empty()
1550        && if data.len() <= HTTP2_PREFACE.len() {
1551            HTTP2_PREFACE.starts_with(data)
1552        } else {
1553            data.starts_with(HTTP2_PREFACE)
1554        }
1555}
1556
1557fn has_complete_http2_preface(data: &[u8]) -> bool {
1558    data.len() >= HTTP2_PREFACE.len() && data.starts_with(HTTP2_PREFACE)
1559}
1560
1561fn http2_frame_payload_len(header: &[u8]) -> usize {
1562    ((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize
1563}
1564
1565fn parse_http2_frame(raw: &[u8]) -> Result<Http2Frame<'_>, ViolationAction> {
1566    if raw.len() < 9 {
1567        return Err(ViolationAction::Block);
1568    }
1569    let len = http2_frame_payload_len(raw);
1570    if raw.len() != 9 + len {
1571        return Err(ViolationAction::Block);
1572    }
1573
1574    let stream_id = u32::from_be_bytes([raw[5], raw[6], raw[7], raw[8]]) & 0x7fff_ffff;
1575    Ok(Http2Frame {
1576        kind: raw[3],
1577        flags: raw[4],
1578        stream_id,
1579        payload: &raw[9..],
1580        raw,
1581    })
1582}
1583
1584fn http2_headers_fragment(flags: u8, payload: &[u8]) -> Result<&[u8], ViolationAction> {
1585    let mut start = 0;
1586    let pad_len = if flags & HTTP2_FLAG_PADDED != 0 {
1587        let Some(pad_len) = payload.first() else {
1588            return Err(ViolationAction::Block);
1589        };
1590        start = 1;
1591        *pad_len as usize
1592    } else {
1593        0
1594    };
1595
1596    if flags & HTTP2_FLAG_PRIORITY != 0 {
1597        start += 5;
1598    }
1599    if payload.len() < start + pad_len {
1600        return Err(ViolationAction::Block);
1601    }
1602
1603    Ok(&payload[start..payload.len() - pad_len])
1604}
1605
1606fn http2_data_payload(flags: u8, payload: &[u8]) -> Result<&[u8], ViolationAction> {
1607    if flags & HTTP2_FLAG_PADDED == 0 {
1608        return Ok(payload);
1609    }
1610
1611    let Some(pad_len) = payload.first() else {
1612        return Err(ViolationAction::Block);
1613    };
1614    let pad_len = *pad_len as usize;
1615    if payload.len() < 1 + pad_len {
1616        return Err(ViolationAction::Block);
1617    }
1618
1619    Ok(&payload[1..payload.len() - pad_len])
1620}
1621
1622fn append_http2_header_frames(
1623    output: &mut Vec<u8>,
1624    stream_id: u32,
1625    end_stream: bool,
1626    block: &[u8],
1627) -> Result<(), ViolationAction> {
1628    let mut first = true;
1629    let mut offset = 0;
1630
1631    while first || offset < block.len() {
1632        let remaining = block.len().saturating_sub(offset);
1633        let take = remaining.min(HTTP2_OUTBOUND_FRAME_PAYLOAD_BYTES);
1634        let payload = &block[offset..offset + take];
1635        offset += take;
1636
1637        let kind = if first {
1638            HTTP2_FRAME_HEADERS
1639        } else {
1640            HTTP2_FRAME_CONTINUATION
1641        };
1642        let mut flags = 0;
1643        if offset == block.len() {
1644            flags |= HTTP2_FLAG_END_HEADERS;
1645        }
1646        if first && end_stream {
1647            flags |= HTTP2_FLAG_END_STREAM;
1648        }
1649
1650        append_http2_frame(output, kind, flags, stream_id, payload)?;
1651        first = false;
1652    }
1653
1654    Ok(())
1655}
1656
1657fn append_http2_frame(
1658    output: &mut Vec<u8>,
1659    kind: u8,
1660    flags: u8,
1661    stream_id: u32,
1662    payload: &[u8],
1663) -> Result<(), ViolationAction> {
1664    if payload.len() > 0x00ff_ffff || stream_id & 0x8000_0000 != 0 {
1665        return Err(ViolationAction::Block);
1666    }
1667
1668    output.push(((payload.len() >> 16) & 0xff) as u8);
1669    output.push(((payload.len() >> 8) & 0xff) as u8);
1670    output.push((payload.len() & 0xff) as u8);
1671    output.push(kind);
1672    output.push(flags);
1673    output.extend_from_slice(&stream_id.to_be_bytes());
1674    output.extend_from_slice(payload);
1675    Ok(())
1676}
1677
1678fn validate_http2_authority(
1679    headers: &[(Vec<u8>, Vec<u8>)],
1680    sni: &str,
1681    require_authority: bool,
1682) -> Result<(), ViolationAction> {
1683    let mut authority_count = 0usize;
1684
1685    for (name, value) in headers {
1686        if name.eq_ignore_ascii_case(b":authority") {
1687            authority_count += 1;
1688            let authority = String::from_utf8_lossy(value);
1689            if !authority_matches_sni(authority.as_ref(), sni) {
1690                return Err(ViolationAction::Block);
1691            }
1692        } else if name.eq_ignore_ascii_case(b"host") {
1693            let host = String::from_utf8_lossy(value);
1694            if !authority_matches_sni(host.as_ref(), sni) {
1695                return Err(ViolationAction::Block);
1696            }
1697        }
1698    }
1699
1700    if require_authority && authority_count != 1 {
1701        return Err(ViolationAction::Block);
1702    }
1703
1704    Ok(())
1705}
1706
1707fn http2_header_detection_bytes(headers: &[(Vec<u8>, Vec<u8>)]) -> Vec<u8> {
1708    let len = headers
1709        .iter()
1710        .map(|(name, value)| name.len() + value.len() + 4)
1711        .sum();
1712    let mut out = Vec::with_capacity(len);
1713    for (name, value) in headers {
1714        out.extend_from_slice(name);
1715        out.extend_from_slice(b": ");
1716        out.extend_from_slice(value);
1717        out.extend_from_slice(b"\r\n");
1718    }
1719    out
1720}
1721
1722fn parse_http_request_metadata(
1723    header_bytes: &[u8],
1724) -> Result<Option<HttpRequestMetadata>, ViolationAction> {
1725    let headers = String::from_utf8_lossy(header_bytes);
1726    let mut lines = headers.split("\r\n");
1727    let Some(request_line) = lines.next() else {
1728        return Ok(None);
1729    };
1730    if request_line.is_empty() {
1731        return Ok(None);
1732    }
1733
1734    let Some(version) = http_request_version(request_line) else {
1735        return Ok(None);
1736    };
1737    if version == "HTTP/2.0" {
1738        return Err(ViolationAction::Block);
1739    }
1740    if !version.starts_with("HTTP/1.") {
1741        return Ok(None);
1742    }
1743
1744    let mut host_headers = Vec::new();
1745    for line in lines.take_while(|line| !line.is_empty()) {
1746        let Some((name, value)) = line.split_once(':') else {
1747            continue;
1748        };
1749        let value = value.trim();
1750
1751        if name.eq_ignore_ascii_case("host") {
1752            host_headers.push(value.to_string());
1753        }
1754    }
1755
1756    if host_headers.is_empty() {
1757        return Err(ViolationAction::Block);
1758    }
1759
1760    Ok(Some(HttpRequestMetadata { host_headers }))
1761}
1762
1763fn http_request_version(request_line: &str) -> Option<&str> {
1764    let mut parts = request_line.split_whitespace();
1765    let method = parts.next()?;
1766    let _target = parts.next()?;
1767    let version = parts.next()?;
1768    if parts.next().is_some() || !method.bytes().all(is_http_token_byte) {
1769        return None;
1770    }
1771    Some(version)
1772}
1773
1774fn looks_like_http_request_prefix(data: &[u8]) -> bool {
1775    if data.is_empty() || b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".starts_with(data) {
1776        return true;
1777    }
1778
1779    let method_end = data.iter().position(|b| *b == b' ');
1780    let method = match method_end {
1781        Some(end) => &data[..end],
1782        None => data,
1783    };
1784
1785    !method.is_empty() && method.iter().copied().all(is_http_token_byte)
1786}
1787
1788fn first_line_is_not_http_request(data: &[u8]) -> bool {
1789    let Some(line_end) = data.windows(2).position(|window| window == b"\r\n") else {
1790        return false;
1791    };
1792    let line = String::from_utf8_lossy(&data[..line_end]);
1793    http_request_version(line.as_ref()).is_none()
1794}
1795
1796fn is_http_token_byte(byte: u8) -> bool {
1797    matches!(
1798        byte,
1799        b'!' | b'#'
1800            | b'$'
1801            | b'%'
1802            | b'&'
1803            | b'\''
1804            | b'*'
1805            | b'+'
1806            | b'-'
1807            | b'.'
1808            | b'^'
1809            | b'_'
1810            | b'`'
1811            | b'|'
1812            | b'~'
1813            | b'0'..=b'9'
1814            | b'A'..=b'Z'
1815            | b'a'..=b'z'
1816    )
1817}
1818
1819fn authority_matches_sni(authority: &str, sni: &str) -> bool {
1820    authority_hostname(authority)
1821        .is_some_and(|hostname| hostname.eq_ignore_ascii_case(sni.trim_end_matches('.')))
1822}
1823
1824fn authority_hostname(authority: &str) -> Option<&str> {
1825    let authority = authority.trim().trim_end_matches('.');
1826    if authority.is_empty() {
1827        return None;
1828    }
1829
1830    if let Some(rest) = authority.strip_prefix('[') {
1831        let (host, _port) = rest.split_once(']')?;
1832        return Some(host.trim_end_matches('.'));
1833    }
1834
1835    match authority.rsplit_once(':') {
1836        Some((host, port)) if !host.contains(':') && port.parse::<u16>().is_ok() => {
1837            Some(host.trim_end_matches('.'))
1838        }
1839        _ => Some(authority),
1840    }
1841}
1842
1843fn secret_host_allowed(
1844    secret: &SecretEntry,
1845    sni: &str,
1846    identity: Option<&SecretHostIdentity<'_>>,
1847) -> bool {
1848    secret
1849        .allowed_hosts
1850        .iter()
1851        .any(|pattern| host_pattern_allowed(pattern, sni, identity))
1852}
1853
1854fn host_pattern_allowed(
1855    pattern: &HostPattern,
1856    sni: &str,
1857    identity: Option<&SecretHostIdentity<'_>>,
1858) -> bool {
1859    if !pattern.matches(sni) {
1860        return false;
1861    }
1862    if matches!(pattern, HostPattern::Any) {
1863        return true;
1864    }
1865    let Some(identity) = identity else {
1866        return true;
1867    };
1868
1869    host_alias_matches(pattern, sni, identity)
1870        || identity
1871            .shared
1872            .any_resolved_hostname(identity.guest_ip, |hostname| pattern.matches(hostname))
1873}
1874
1875fn host_alias_matches(pattern: &HostPattern, sni: &str, identity: &SecretHostIdentity<'_>) -> bool {
1876    if !sni.eq_ignore_ascii_case(crate::HOST_ALIAS) || !pattern.matches(crate::HOST_ALIAS) {
1877        return false;
1878    }
1879
1880    identity
1881        .shared
1882        .gateway_ipv4()
1883        .is_some_and(|ip| identity.guest_ip == IpAddr::V4(ip))
1884        || identity
1885            .shared
1886            .gateway_ipv6()
1887            .is_some_and(|ip| identity.guest_ip == IpAddr::V6(ip))
1888}
1889
1890fn effective_violation_action<'a>(
1891    secret: &'a SecretEntry,
1892    config: &'a SecretsConfig,
1893    sni: &str,
1894    identity: Option<&SecretHostIdentity<'_>>,
1895) -> &'a ViolationAction {
1896    match &secret.on_violation {
1897        Some(ViolationAction::Passthrough(hosts))
1898            if !hosts
1899                .iter()
1900                .any(|pattern| host_pattern_allowed(pattern, sni, identity)) =>
1901        {
1902            &config.on_violation
1903        }
1904        Some(action) => action,
1905        None => &config.on_violation,
1906    }
1907}
1908
1909/// Decode the credentials of a `Basic` `Authorization` header line. Returns
1910/// `None` if the line is not `Basic`-scheme or the payload is not valid
1911/// base64 / UTF-8.
1912fn decode_basic_credentials(line: &str) -> Option<String> {
1913    let (_, raw_value) = line.split_once(':')?;
1914    let (scheme, encoded) = split_auth_scheme(raw_value.trim_start())?;
1915    if !scheme.eq_ignore_ascii_case("basic") {
1916        return None;
1917    }
1918    let bytes = BASE64.decode(encoded.trim()).ok()?;
1919    String::from_utf8(bytes).ok()
1920}
1921
1922/// Split an `Authorization` header value into `(scheme, rest)` at the first
1923/// whitespace. Returns `None` if no whitespace separator is found.
1924fn split_auth_scheme(header_value: &str) -> Option<(&str, &str)> {
1925    let split_at = header_value.find(char::is_whitespace)?;
1926    let (scheme, rest) = header_value.split_at(split_at);
1927    Some((scheme, rest.trim_start()))
1928}
1929
1930fn substitute_query_in_request_line(line: &str, placeholder: &str, value: &str) -> Option<String> {
1931    if placeholder.is_empty() {
1932        return None;
1933    }
1934
1935    let method_end = line.find(' ')?;
1936    let target_start = method_end + 1;
1937    let version_start = line[target_start..].rfind(' ')? + target_start;
1938    if version_start <= target_start {
1939        return None;
1940    }
1941
1942    let target = &line[target_start..version_start];
1943    let query_start = target.find('?')? + 1;
1944    let query = &target[query_start..];
1945    if !query.contains(placeholder) {
1946        return None;
1947    }
1948
1949    let mut result = String::with_capacity(line.len());
1950    result.push_str(&line[..target_start + query_start]);
1951    result.push_str(&query.replace(placeholder, value));
1952    result.push_str(&line[version_start..]);
1953    Some(result)
1954}
1955
1956fn substitute_query_in_target(target: &str, placeholder: &str, value: &str) -> Option<String> {
1957    if placeholder.is_empty() {
1958        return None;
1959    }
1960
1961    let query_start = target.find('?')? + 1;
1962    let query = &target[query_start..];
1963    if !query.contains(placeholder) {
1964        return None;
1965    }
1966
1967    let mut result = String::with_capacity(target.len());
1968    result.push_str(&target[..query_start]);
1969    result.push_str(&query.replace(placeholder, value));
1970    Some(result)
1971}
1972
1973fn substitute_basic_auth_value(
1974    header_value: &str,
1975    placeholder: &str,
1976    value: &str,
1977) -> Option<String> {
1978    let (scheme, encoded) = split_auth_scheme(header_value.trim_start())?;
1979    if !scheme.eq_ignore_ascii_case("basic") {
1980        return None;
1981    }
1982    let bytes = BASE64.decode(encoded.trim()).ok()?;
1983    let decoded = String::from_utf8(bytes).ok()?;
1984    if !decoded.contains(placeholder) {
1985        return None;
1986    }
1987    let replaced = decoded.replace(placeholder, value);
1988    Some(format!("Basic {}", BASE64.encode(replaced.as_bytes())))
1989}
1990
1991/// Returns true if any `Authorization: Basic` line in `headers` decodes to
1992/// credentials containing `placeholder`.
1993fn basic_auth_decoded_contains(headers: &str, placeholder: &str) -> bool {
1994    decoded_basic_auth_credentials(headers)
1995        .iter()
1996        .any(|decoded| decoded.contains(placeholder))
1997}
1998
1999/// Decode all Basic authorization credentials in an HTTP header block.
2000fn decoded_basic_auth_credentials(headers: &str) -> Vec<String> {
2001    headers
2002        .split("\r\n")
2003        .filter(|line| is_authorization_header(line))
2004        .filter_map(decode_basic_credentials)
2005        .collect()
2006}
2007
2008/// Byte-slice substring check.
2009fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
2010    if needle.is_empty() || haystack.len() < needle.len() {
2011        return false;
2012    }
2013    haystack.windows(needle.len()).any(|w| w == needle)
2014}
2015
2016/// Longest representation the violation detector may need to carry across
2017/// write boundaries for a placeholder. Percent encoding can expand one byte
2018/// to `%XX`; JSON unicode escaping can expand one byte to `\u00XX`.
2019fn max_placeholder_detection_len(placeholder_len: usize) -> usize {
2020    placeholder_len.saturating_mul(6)
2021}
2022
2023/// Compute the framing state for the next chunk and how many of the
2024/// post-boundary bytes belong to THIS request's body. `body_in_chunk` is
2025/// the number of bytes that followed `\r\n\r\n` in this chunk; the
2026/// returned `body_in_request` is at most `body_in_chunk`, and any
2027/// remaining bytes are spillover from a pipelined next request.
2028fn next_state_after_headers(
2029    headers: &str,
2030    body_bytes: &[u8],
2031) -> Result<RequestFraming, ViolationAction> {
2032    let body_in_chunk = body_bytes.len();
2033    let body_substitution_allowed = !has_non_identity_content_encoding(headers);
2034    if is_transfer_chunked(headers) {
2035        let mut chunked_state = ChunkedBodyState::default();
2036        let (state, body_in_request) = match consume_chunked_body(&mut chunked_state, body_bytes)? {
2037            Some(end) => (HttpState::AwaitingHeaders, end),
2038            _ => (
2039                HttpState::InChunkedBody {
2040                    state: chunked_state,
2041                },
2042                body_in_chunk,
2043            ),
2044        };
2045        return Ok(RequestFraming {
2046            state,
2047            body_in_request,
2048            body_substitution_allowed: false,
2049        });
2050    }
2051    match parse_content_length(headers)? {
2052        Some(cl) if body_in_chunk >= cl => Ok(RequestFraming {
2053            state: HttpState::AwaitingHeaders,
2054            body_in_request: cl,
2055            body_substitution_allowed,
2056        }),
2057        Some(cl) => Ok(RequestFraming {
2058            state: HttpState::InBody {
2059                remaining: cl - body_in_chunk,
2060            },
2061            body_in_request: body_in_chunk,
2062            body_substitution_allowed,
2063        }),
2064        // Per RFC 9112 §6.3 case 6, a request with neither `Content-Length`
2065        // nor `Transfer-Encoding` has a zero-length body. Any trailing
2066        // bytes are the start of a pipelined next request.
2067        None => Ok(RequestFraming {
2068            state: HttpState::AwaitingHeaders,
2069            body_in_request: 0,
2070            body_substitution_allowed: false,
2071        }),
2072    }
2073}
2074
2075/// Parse a `Content-Length:` value from the headers block. Case-insensitive
2076/// header name match; rejects malformed or conflicting values.
2077fn parse_content_length(headers: &str) -> Result<Option<usize>, ViolationAction> {
2078    let mut content_length = None;
2079    for line in headers.split("\r\n") {
2080        let Some((name, value)) = line.split_once(':') else {
2081            continue;
2082        };
2083        if name.eq_ignore_ascii_case("content-length") {
2084            let parsed = value
2085                .trim()
2086                .parse::<usize>()
2087                .map_err(|_| ViolationAction::Block)?;
2088            if content_length.is_some_and(|existing| existing != parsed) {
2089                return Err(ViolationAction::Block);
2090            }
2091            content_length = Some(parsed);
2092        }
2093    }
2094    Ok(content_length)
2095}
2096
2097fn content_length_exceeds_buffer_limit(headers: &str) -> Result<bool, ViolationAction> {
2098    Ok(parse_content_length(headers)?.is_some_and(|len| len > MAX_HTTP_BODY_BUFFER_BYTES))
2099}
2100
2101/// True if the headers contain `Transfer-Encoding: chunked` (case-insensitive,
2102/// last value in the comma-list per RFC 7230).
2103fn is_transfer_chunked(headers: &str) -> bool {
2104    for line in headers.split("\r\n") {
2105        let Some((name, value)) = line.split_once(':') else {
2106            continue;
2107        };
2108        if name.eq_ignore_ascii_case("transfer-encoding")
2109            && value
2110                .split(',')
2111                .next_back()
2112                .map(|s| s.trim().eq_ignore_ascii_case("chunked"))
2113                .unwrap_or(false)
2114        {
2115            return true;
2116        }
2117    }
2118    false
2119}
2120
2121/// True when the request body is encoded and cannot be rewritten byte-for-byte.
2122fn has_non_identity_content_encoding(headers: &str) -> bool {
2123    for line in headers.split("\r\n") {
2124        let Some((name, value)) = line.split_once(':') else {
2125            continue;
2126        };
2127        if !name.eq_ignore_ascii_case("content-encoding") {
2128            continue;
2129        }
2130        if value
2131            .split(',')
2132            .any(|encoding| !encoding.trim().eq_ignore_ascii_case("identity"))
2133        {
2134            return true;
2135        }
2136    }
2137    false
2138}
2139
2140/// Replace all occurrences of `needle` in `haystack`.
2141///
2142/// Returns `None` when no replacement is needed so callers can preserve the
2143/// original byte slice without rebuilding arbitrary binary payloads.
2144fn replace_bytes(haystack: &[u8], needle: &[u8], replacement: &[u8]) -> Option<Vec<u8>> {
2145    if !contains_bytes(haystack, needle) {
2146        return None;
2147    }
2148
2149    let mut result = Vec::with_capacity(haystack.len());
2150    let mut cursor = 0;
2151    while cursor < haystack.len() {
2152        if haystack[cursor..].starts_with(needle) {
2153            result.extend_from_slice(replacement);
2154            cursor += needle.len();
2155        } else {
2156            result.push(haystack[cursor]);
2157            cursor += 1;
2158        }
2159    }
2160    Some(result)
2161}
2162
2163/// Returns true if `haystack`, after URL percent-decoding, contains `needle`.
2164#[cfg(test)]
2165fn url_decoded_contains(haystack: &[u8], needle: &[u8]) -> bool {
2166    let decoded: Vec<u8> = percent_decode(haystack).collect();
2167    contains_bytes(&decoded, needle)
2168}
2169
2170/// Returns true if `haystack`, after JSON `\uXXXX` decoding, contains `needle`.
2171/// Only `\uXXXX` escapes are expanded (sufficient to detect ASCII placeholders
2172/// hidden via unicode escapes); other JSON escapes pass through.
2173#[cfg(test)]
2174fn json_escaped_contains(haystack: &[u8], needle: &[u8]) -> bool {
2175    let decoded = json_unescape(haystack);
2176    contains_bytes(&decoded, needle)
2177}
2178
2179/// Decode JSON `\uXXXX` escapes in a byte slice.
2180fn json_unescape(haystack: &[u8]) -> Vec<u8> {
2181    let mut decoded = Vec::with_capacity(haystack.len());
2182    let mut i = 0;
2183    while i < haystack.len() {
2184        if haystack[i] == b'\\'
2185            && i + 5 < haystack.len()
2186            && haystack[i + 1] == b'u'
2187            && let (Some(a), Some(b), Some(c), Some(d)) = (
2188                hex_digit(haystack[i + 2]),
2189                hex_digit(haystack[i + 3]),
2190                hex_digit(haystack[i + 4]),
2191                hex_digit(haystack[i + 5]),
2192            )
2193        {
2194            let cp = ((a as u32) << 12) | ((b as u32) << 8) | ((c as u32) << 4) | (d as u32);
2195            if let Some(ch) = char::from_u32(cp) {
2196                let mut buf = [0u8; 4];
2197                decoded.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
2198            }
2199            i += 6;
2200            continue;
2201        }
2202        decoded.push(haystack[i]);
2203        i += 1;
2204    }
2205    decoded
2206}
2207
2208fn hex_digit(b: u8) -> Option<u8> {
2209    (b as char).to_digit(16).map(|d| d as u8)
2210}
2211
2212/// Update the Content-Length header value in `headers` to `new_len`.
2213///
2214/// Performs a case-insensitive line scan. If no Content-Length header exists
2215/// (e.g. chunked transfer encoding), the headers are returned unchanged.
2216fn update_content_length(headers: &str, new_len: usize) -> String {
2217    let mut result = String::with_capacity(headers.len());
2218    for (i, line) in headers.split("\r\n").enumerate() {
2219        if i > 0 {
2220            result.push_str("\r\n");
2221        }
2222        if line
2223            .as_bytes()
2224            .get(..15)
2225            .is_some_and(|b| b.eq_ignore_ascii_case(b"content-length:"))
2226        {
2227            result.push_str(&format!("Content-Length: {new_len}"));
2228        } else {
2229            result.push_str(line);
2230        }
2231    }
2232    result
2233}
2234
2235/// Find the `\r\n\r\n` boundary between HTTP headers and body.
2236fn find_header_boundary(data: &[u8]) -> Option<usize> {
2237    data.windows(4)
2238        .position(|w| w == b"\r\n\r\n")
2239        .map(|pos| pos + 4)
2240}
2241
2242fn append_chunk(output: &mut Vec<u8>, payload: &[u8]) {
2243    if payload.is_empty() {
2244        return;
2245    }
2246    output.extend_from_slice(format!("{:X}\r\n", payload.len()).as_bytes());
2247    output.extend_from_slice(payload);
2248    output.extend_from_slice(b"\r\n");
2249}
2250
2251fn detect_blocking_action_with_tail(
2252    ineligible_for_substitution: &[IneligibleSecret],
2253    prev_tail: &[u8],
2254    data: &[u8],
2255    headers: &str,
2256) -> Option<BlockingAction> {
2257    if ineligible_for_substitution.is_empty() {
2258        return None;
2259    }
2260
2261    let scan_buf: Cow<[u8]> = if prev_tail.is_empty() {
2262        Cow::Borrowed(data)
2263    } else {
2264        let mut stitched = Vec::with_capacity(prev_tail.len() + data.len());
2265        stitched.extend_from_slice(prev_tail);
2266        stitched.extend_from_slice(data);
2267        Cow::Owned(stitched)
2268    };
2269    let scan = scan_buf.as_ref();
2270    let url_decoded = scan
2271        .contains(&b'%')
2272        .then(|| percent_decode(scan).collect::<Vec<u8>>());
2273    let json_decoded = scan
2274        .windows(2)
2275        .any(|window| window == b"\\u")
2276        .then(|| json_unescape(scan));
2277    let basic_auth_credentials = decoded_basic_auth_credentials(headers);
2278
2279    let mut detected = None;
2280    for secret in ineligible_for_substitution {
2281        let needle = secret.placeholder.as_bytes();
2282        if contains_bytes(scan, needle)
2283            || url_decoded
2284                .as_deref()
2285                .is_some_and(|decoded| contains_bytes(decoded, needle))
2286            || json_decoded
2287                .as_deref()
2288                .is_some_and(|decoded| contains_bytes(decoded, needle))
2289            || basic_auth_credentials
2290                .iter()
2291                .any(|decoded| decoded.contains(&secret.placeholder))
2292        {
2293            detected = Some(strictest_violation_action(detected, secret.action));
2294        }
2295    }
2296
2297    detected
2298}
2299
2300fn update_tail_buffer(tail: &mut Vec<u8>, data: &[u8], tail_size: usize) {
2301    if tail_size == 0 {
2302        tail.clear();
2303        return;
2304    }
2305    if data.len() >= tail_size {
2306        tail.clear();
2307        tail.extend_from_slice(&data[data.len() - tail_size..]);
2308        return;
2309    }
2310    tail.extend_from_slice(data);
2311    let overflow = tail.len().saturating_sub(tail_size);
2312    if overflow > 0 {
2313        tail.drain(..overflow);
2314    }
2315}
2316
2317/// Consume chunked body bytes and return the position after the body when the
2318/// terminating zero chunk and trailers are complete.
2319fn consume_chunked_body(
2320    state: &mut ChunkedBodyState,
2321    data: &[u8],
2322) -> Result<Option<usize>, ViolationAction> {
2323    process_chunked_body(state, data, |_| Ok(()))
2324}
2325
2326/// Process chunked body bytes and call `on_payload` with decoded chunk payload
2327/// slices, `on_zero_chunk` when the terminating chunk is parsed, and
2328/// `on_trailer_line` with each complete trailer line including its CRLF.
2329fn process_chunked_body<E>(
2330    state: &mut ChunkedBodyState,
2331    data: &[u8],
2332    mut on_event: E,
2333) -> Result<Option<usize>, ViolationAction>
2334where
2335    E: FnMut(ChunkedBodyEvent<'_>) -> Result<(), ViolationAction>,
2336{
2337    let mut cursor = 0;
2338    while cursor < data.len() {
2339        let phase = std::mem::replace(&mut state.phase, ChunkedPhase::SizeLine);
2340        match phase {
2341            ChunkedPhase::SizeLine => {
2342                state.line.push(data[cursor]);
2343                cursor += 1;
2344                if state.line.len() > MAX_HTTP_HEADER_BYTES {
2345                    return Err(ViolationAction::Block);
2346                }
2347                if state.line.ends_with(b"\r\n") {
2348                    let line = &state.line[..state.line.len() - 2];
2349                    let size = parse_chunk_size(line)?;
2350                    state.line.clear();
2351                    state.phase = if size == 0 {
2352                        on_event(ChunkedBodyEvent::ZeroChunk)?;
2353                        ChunkedPhase::TrailerLine
2354                    } else {
2355                        ChunkedPhase::Data { remaining: size }
2356                    };
2357                } else {
2358                    state.phase = ChunkedPhase::SizeLine;
2359                }
2360            }
2361            ChunkedPhase::Data { mut remaining } => {
2362                let take = remaining.min(data.len() - cursor);
2363                on_event(ChunkedBodyEvent::Payload(&data[cursor..cursor + take]))?;
2364                cursor += take;
2365                remaining -= take;
2366                if remaining == 0 {
2367                    state.phase = ChunkedPhase::DataCrlf { seen_cr: false };
2368                } else {
2369                    state.phase = ChunkedPhase::Data { remaining };
2370                }
2371            }
2372            ChunkedPhase::DataCrlf { mut seen_cr } => {
2373                if !seen_cr {
2374                    if data[cursor] != b'\r' {
2375                        return Err(ViolationAction::Block);
2376                    }
2377                    seen_cr = true;
2378                    cursor += 1;
2379                    state.phase = ChunkedPhase::DataCrlf { seen_cr };
2380                } else {
2381                    if data[cursor] != b'\n' {
2382                        return Err(ViolationAction::Block);
2383                    }
2384                    state.phase = ChunkedPhase::SizeLine;
2385                    cursor += 1;
2386                }
2387            }
2388            ChunkedPhase::TrailerLine => {
2389                state.line.push(data[cursor]);
2390                cursor += 1;
2391                if state.line.len() > MAX_HTTP_HEADER_BYTES {
2392                    return Err(ViolationAction::Block);
2393                }
2394                if state.line.ends_with(b"\r\n") {
2395                    let is_empty = state.line.len() == 2;
2396                    on_event(ChunkedBodyEvent::TrailerLine(&state.line))?;
2397                    state.line.clear();
2398                    if is_empty {
2399                        return Ok(Some(cursor));
2400                    }
2401                    state.phase = ChunkedPhase::TrailerLine;
2402                } else {
2403                    state.phase = ChunkedPhase::TrailerLine;
2404                }
2405            }
2406        }
2407    }
2408
2409    Ok(None)
2410}
2411
2412fn parse_chunk_size(line: &[u8]) -> Result<usize, ViolationAction> {
2413    let size = line
2414        .split(|byte| *byte == b';')
2415        .next()
2416        .unwrap_or_default()
2417        .trim_ascii();
2418    if size.is_empty() {
2419        return Err(ViolationAction::Block);
2420    }
2421    let size = std::str::from_utf8(size).map_err(|_| ViolationAction::Block)?;
2422    usize::from_str_radix(size, 16).map_err(|_| ViolationAction::Block)
2423}
2424
2425/// Returns the stricter of two blocking actions, where
2426/// `BlockAndTerminate` > `BlockAndLog` > `Block`.
2427fn strictest_violation_action(
2428    current: Option<BlockingAction>,
2429    candidate: BlockingAction,
2430) -> BlockingAction {
2431    match (current, candidate) {
2432        (Some(BlockingAction::BlockAndTerminate), _) | (_, BlockingAction::BlockAndTerminate) => {
2433            BlockingAction::BlockAndTerminate
2434        }
2435        (Some(BlockingAction::BlockAndLog), _) | (_, BlockingAction::BlockAndLog) => {
2436            BlockingAction::BlockAndLog
2437        }
2438        (Some(BlockingAction::Block), _) | (None, BlockingAction::Block) => BlockingAction::Block,
2439    }
2440}
2441
2442//--------------------------------------------------------------------------------------------------
2443// Tests
2444//--------------------------------------------------------------------------------------------------
2445
2446#[cfg(test)]
2447mod tests {
2448    use super::*;
2449    use crate::secrets::config::*;
2450    use crate::shared::{ResolvedHostnameFamily, SharedState};
2451
2452    use std::net::{IpAddr, Ipv4Addr};
2453    use std::time::Duration;
2454
2455    fn make_config(secrets: Vec<SecretEntry>) -> SecretsConfig {
2456        SecretsConfig {
2457            secrets,
2458            on_violation: ViolationAction::Block,
2459        }
2460    }
2461
2462    fn make_secret(placeholder: &str, value: &str, host: &str) -> SecretEntry {
2463        SecretEntry {
2464            env_var: "TEST_KEY".into(),
2465            value: value.into(),
2466            placeholder: placeholder.into(),
2467            allowed_hosts: vec![HostPattern::Exact(host.into())],
2468            injection: SecretInjection::default(),
2469            on_violation: None,
2470            require_tls_identity: true,
2471        }
2472    }
2473
2474    fn cache_host(shared: &SharedState, host: &str, ip: Ipv4Addr) {
2475        shared.cache_resolved_hostname(
2476            host,
2477            ResolvedHostnameFamily::Ipv4,
2478            [IpAddr::V4(ip)],
2479            Duration::from_secs(60),
2480        );
2481    }
2482
2483    fn basic_auth_only() -> SecretInjection {
2484        SecretInjection {
2485            headers: false,
2486            basic_auth: true,
2487            query_params: false,
2488            body: false,
2489        }
2490    }
2491
2492    fn split_http_body(data: &[u8]) -> (&[u8], &[u8]) {
2493        let boundary = find_header_boundary(data).expect("HTTP header boundary");
2494        data.split_at(boundary)
2495    }
2496
2497    fn decode_chunked_payload(data: &[u8]) -> (Vec<u8>, Vec<u8>, usize) {
2498        let mut cursor = 0;
2499        let mut decoded = Vec::new();
2500        let mut trailers = Vec::new();
2501
2502        loop {
2503            let line_end = data[cursor..]
2504                .windows(2)
2505                .position(|window| window == b"\r\n")
2506                .map(|pos| cursor + pos)
2507                .expect("chunk size line");
2508            let size = parse_chunk_size(&data[cursor..line_end]).expect("valid chunk size");
2509            cursor = line_end + 2;
2510
2511            if size == 0 {
2512                loop {
2513                    let trailer_end = data[cursor..]
2514                        .windows(2)
2515                        .position(|window| window == b"\r\n")
2516                        .map(|pos| cursor + pos + 2)
2517                        .expect("trailer line");
2518                    trailers.extend_from_slice(&data[cursor..trailer_end]);
2519                    let empty = trailer_end - cursor == 2;
2520                    cursor = trailer_end;
2521                    if empty {
2522                        return (decoded, trailers, cursor);
2523                    }
2524                }
2525            }
2526
2527            decoded.extend_from_slice(&data[cursor..cursor + size]);
2528            cursor += size;
2529            assert_eq!(&data[cursor..cursor + 2], b"\r\n");
2530            cursor += 2;
2531        }
2532    }
2533
2534    fn encode_h2_header_block(headers: &[(&[u8], &[u8])]) -> Vec<u8> {
2535        let mut encoder = HpackEncoder::with_dynamic_size(4096);
2536        let mut block = Vec::new();
2537        for (name, value) in headers {
2538            encoder
2539                .encode(
2540                    (name.to_vec(), value.to_vec(), HpackEncoder::NEVER_INDEXED),
2541                    &mut block,
2542                )
2543                .unwrap();
2544        }
2545        block
2546    }
2547
2548    fn h2_request(headers: &[(&[u8], &[u8])], end_stream: bool) -> Vec<u8> {
2549        let encoded = encode_h2_header_block(headers);
2550        let mut out = HTTP2_PREFACE.to_vec();
2551        append_http2_frame(&mut out, 0x4, 0, 0, &[]).unwrap();
2552        append_http2_header_frames(&mut out, 1, end_stream, &encoded).unwrap();
2553        out
2554    }
2555
2556    fn h2_request_with_split_headers(headers: &[(&[u8], &[u8])], split_at: usize) -> Vec<u8> {
2557        let encoded = encode_h2_header_block(headers);
2558        let split_at = split_at.min(encoded.len());
2559        let mut out = HTTP2_PREFACE.to_vec();
2560        append_http2_frame(&mut out, 0x4, 0, 0, &[]).unwrap();
2561        append_http2_frame(&mut out, HTTP2_FRAME_HEADERS, 0, 1, &encoded[..split_at]).unwrap();
2562        append_http2_frame(
2563            &mut out,
2564            HTTP2_FRAME_CONTINUATION,
2565            HTTP2_FLAG_END_HEADERS | HTTP2_FLAG_END_STREAM,
2566            1,
2567            &encoded[split_at..],
2568        )
2569        .unwrap();
2570        out
2571    }
2572
2573    fn h2_request_with_data(headers: &[(&[u8], &[u8])], data: &[u8]) -> Vec<u8> {
2574        let mut out = h2_request(headers, false);
2575        append_http2_frame(&mut out, HTTP2_FRAME_DATA, HTTP2_FLAG_END_STREAM, 1, data).unwrap();
2576        out
2577    }
2578
2579    fn append_h2_headers(
2580        out: &mut Vec<u8>,
2581        stream_id: u32,
2582        headers: &[(&[u8], &[u8])],
2583        end_stream: bool,
2584    ) {
2585        let encoded = encode_h2_header_block(headers);
2586        append_http2_header_frames(out, stream_id, end_stream, &encoded).unwrap();
2587    }
2588
2589    fn decode_first_h2_headers(data: &[u8]) -> Vec<(Vec<u8>, Vec<u8>)> {
2590        assert!(data.starts_with(HTTP2_PREFACE));
2591        let mut cursor = HTTP2_PREFACE.len();
2592        let mut decoder = HpackDecoder::with_dynamic_size(4096);
2593        let mut header_block = Vec::new();
2594        let mut in_headers = false;
2595
2596        while cursor + 9 <= data.len() {
2597            let len = http2_frame_payload_len(&data[cursor..cursor + 9]);
2598            let raw = &data[cursor..cursor + 9 + len];
2599            cursor += 9 + len;
2600            let frame = parse_http2_frame(raw).unwrap();
2601            match frame.kind {
2602                HTTP2_FRAME_HEADERS => {
2603                    header_block.extend_from_slice(
2604                        http2_headers_fragment(frame.flags, frame.payload).unwrap(),
2605                    );
2606                    if frame.flags & HTTP2_FLAG_END_HEADERS != 0 {
2607                        break;
2608                    }
2609                    in_headers = true;
2610                }
2611                HTTP2_FRAME_CONTINUATION if in_headers => {
2612                    header_block.extend_from_slice(frame.payload);
2613                    if frame.flags & HTTP2_FLAG_END_HEADERS != 0 {
2614                        break;
2615                    }
2616                }
2617                _ => {}
2618            }
2619        }
2620
2621        let mut encoded = header_block;
2622        let mut headers = Vec::new();
2623        decoder.decode(&mut encoded, &mut headers).unwrap();
2624        headers
2625            .into_iter()
2626            .map(|(name, value, _flags)| (name, value))
2627            .collect()
2628    }
2629
2630    fn h2_header_value(headers: &[(Vec<u8>, Vec<u8>)], name: &[u8]) -> String {
2631        let value = headers
2632            .iter()
2633            .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
2634            .map(|(_, value)| value.as_slice())
2635            .expect("header present");
2636        String::from_utf8(value.to_vec()).unwrap()
2637    }
2638
2639    #[test]
2640    fn substitute_in_headers() {
2641        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2642        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2643
2644        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2645        let output = handler.substitute(input).unwrap();
2646        assert_eq!(
2647            String::from_utf8(output.into_owned()).unwrap(),
2648            "GET / HTTP/1.1\r\nAuthorization: Bearer real-secret\r\n\r\n"
2649        );
2650    }
2651
2652    #[test]
2653    fn no_substitute_for_wrong_host() {
2654        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2655        let mut handler = SecretsHandler::new(&config, "evil.com", true);
2656
2657        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2658        assert_eq!(
2659            handler.substitute(input).unwrap_err(),
2660            ViolationAction::Block
2661        );
2662    }
2663
2664    #[test]
2665    fn split_http1_post_is_not_misclassified_as_http2_preface() {
2666        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2667        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2668
2669        assert_eq!(handler.substitute(b"P").unwrap().as_ref(), b"");
2670
2671        let output = handler
2672            .substitute(b"OST / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n")
2673            .unwrap();
2674        assert_eq!(
2675            String::from_utf8(output.into_owned()).unwrap(),
2676            "POST / HTTP/1.1\r\nAuthorization: Bearer real-secret\r\n\r\n"
2677        );
2678    }
2679
2680    #[test]
2681    fn allowed_placeholder_substitutes_when_another_secret_is_ineligible() {
2682        let allowed = make_secret("$ALLOWED", "allowed-secret", "api.openai.com");
2683        let blocked = make_secret("$BLOCKED", "blocked-secret", "api.github.com");
2684        let config = make_config(vec![allowed, blocked]);
2685        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2686
2687        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $ALLOWED\r\n\r\n";
2688        let output = handler.substitute(input).unwrap();
2689
2690        assert_eq!(
2691            String::from_utf8(output.into_owned()).unwrap(),
2692            "GET / HTTP/1.1\r\nAuthorization: Bearer allowed-secret\r\n\r\n"
2693        );
2694    }
2695
2696    #[test]
2697    fn global_passthrough_host_forwards_placeholder_unchanged() {
2698        let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2699        config.on_violation =
2700            ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())]);
2701        let mut handler = SecretsHandler::new(&config, "api.anthropic.com", true);
2702
2703        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2704        let output = handler.substitute(input).unwrap();
2705        assert_eq!(&*output, input);
2706    }
2707
2708    #[test]
2709    fn per_secret_passthrough_host_forwards_placeholder_unchanged() {
2710        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2711        secret.on_violation = Some(ViolationAction::Passthrough(vec![HostPattern::Exact(
2712            "api.anthropic.com".into(),
2713        )]));
2714        let config = make_config(vec![secret]);
2715        let mut handler = SecretsHandler::new(&config, "api.anthropic.com", true);
2716
2717        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2718        let output = handler.substitute(input).unwrap();
2719        assert_eq!(&*output, input);
2720    }
2721
2722    #[test]
2723    fn global_passthrough_action_forwards_disallowed_placeholder_unchanged() {
2724        let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2725        config.on_violation = ViolationAction::Passthrough(vec![HostPattern::Any]);
2726        let mut handler = SecretsHandler::new(&config, "evil.com", true);
2727
2728        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2729        let output = handler.substitute(input).unwrap();
2730        assert_eq!(&*output, input);
2731    }
2732
2733    #[test]
2734    fn passthrough_only_connection_has_no_handler_work() {
2735        let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2736        config.on_violation = ViolationAction::Passthrough(vec![HostPattern::Any]);
2737        let handler = SecretsHandler::new(&config, "evil.com", true);
2738
2739        assert!(handler.is_empty());
2740    }
2741
2742    #[test]
2743    fn passthrough_host_does_not_allow_other_disallowed_placeholders() {
2744        let mut passthrough = make_secret("$PASSTHROUGH", "real-secret-a", "api.openai.com");
2745        passthrough.on_violation = Some(ViolationAction::Passthrough(vec![HostPattern::Exact(
2746            "api.anthropic.com".into(),
2747        )]));
2748        let blocked = make_secret("$BLOCKED", "real-secret-b", "api.github.com");
2749        let config = make_config(vec![passthrough, blocked]);
2750        let mut handler = SecretsHandler::new(&config, "api.anthropic.com", true);
2751
2752        let input = b"GET / HTTP/1.1\r\nX-A: $PASSTHROUGH\r\nX-B: $BLOCKED\r\n\r\n";
2753        assert_eq!(
2754            handler.substitute(input).unwrap_err(),
2755            ViolationAction::Block
2756        );
2757    }
2758
2759    #[test]
2760    fn per_secret_passthrough_blocks_for_non_matching_host() {
2761        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2762        secret.on_violation = Some(ViolationAction::Passthrough(vec![HostPattern::Exact(
2763            "api.anthropic.com".into(),
2764        )]));
2765        let config = make_config(vec![secret]);
2766        let mut handler = SecretsHandler::new(&config, "evil.com", true);
2767
2768        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2769        assert_eq!(
2770            handler.substitute(input).unwrap_err(),
2771            ViolationAction::Block
2772        );
2773    }
2774
2775    #[test]
2776    fn global_passthrough_blocks_for_non_matching_host() {
2777        let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2778        config.on_violation =
2779            ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())]);
2780        let mut handler = SecretsHandler::new(&config, "evil.com", true);
2781
2782        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2783        assert_eq!(
2784            handler.substitute(input).unwrap_err(),
2785            ViolationAction::BlockAndLog
2786        );
2787    }
2788
2789    #[test]
2790    fn global_block_and_terminate_marks_violation_as_terminating() {
2791        let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2792        config.on_violation = ViolationAction::BlockAndTerminate;
2793        let mut handler = SecretsHandler::new(&config, "evil.com", true);
2794
2795        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2796        assert_eq!(
2797            handler.substitute(input).unwrap_err(),
2798            ViolationAction::BlockAndTerminate
2799        );
2800    }
2801
2802    #[test]
2803    fn per_secret_block_and_terminate_marks_violation_as_terminating() {
2804        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2805        secret.on_violation = Some(ViolationAction::BlockAndTerminate);
2806        let config = make_config(vec![secret]);
2807        let mut handler = SecretsHandler::new(&config, "evil.com", true);
2808
2809        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
2810        assert_eq!(
2811            handler.substitute(input).unwrap_err(),
2812            ViolationAction::BlockAndTerminate
2813        );
2814    }
2815
2816    #[test]
2817    fn body_injection_disabled_by_default() {
2818        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2819        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2820
2821        let input = b"POST / HTTP/1.1\r\nContent-Length: 15\r\n\r\n{\"key\": \"$KEY\"}";
2822        let output = handler.substitute(input).unwrap();
2823        assert!(
2824            String::from_utf8(output.into_owned())
2825                .unwrap()
2826                .contains("$KEY")
2827        );
2828    }
2829
2830    #[test]
2831    fn body_injection_when_enabled() {
2832        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2833        secret.injection.body = true;
2834        let config = make_config(vec![secret]);
2835        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2836
2837        let input = b"POST / HTTP/1.1\r\nContent-Length: 15\r\n\r\n{\"key\": \"$KEY\"}";
2838        let output = handler.substitute(input).unwrap();
2839        assert_eq!(
2840            String::from_utf8(output.into_owned()).unwrap(),
2841            "POST / HTTP/1.1\r\nContent-Length: 22\r\n\r\n{\"key\": \"real-secret\"}"
2842        );
2843    }
2844
2845    #[test]
2846    fn body_injection_updates_content_length() {
2847        let mut secret = make_secret("$KEY", "a]longer]secret]value", "api.openai.com");
2848        secret.injection.body = true;
2849        let config = make_config(vec![secret]);
2850        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2851
2852        let body = "{\"key\": \"$KEY\"}";
2853        let input = format!(
2854            "POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}",
2855            body.len(),
2856            body
2857        );
2858        let output = handler.substitute(input.as_bytes()).unwrap();
2859        let result = String::from_utf8(output.into_owned()).unwrap();
2860
2861        let expected_body = "{\"key\": \"a]longer]secret]value\"}";
2862        assert!(result.contains(expected_body));
2863        assert!(result.contains(&format!("Content-Length: {}", expected_body.len())));
2864    }
2865
2866    #[test]
2867    fn body_injection_buffers_until_content_length_complete() {
2868        let mut secret = make_secret("$KEY", "longer-secret", "api.openai.com");
2869        secret.injection.body = true;
2870        let config = make_config(vec![secret]);
2871        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2872
2873        let body = b"{\"key\":\"$KEY\"}";
2874        let mut chunk1 = format!(
2875            "POST / HTTP/1.1\r\nHost: api.openai.com\r\nContent-Length: {}\r\n\r\n",
2876            body.len()
2877        )
2878        .into_bytes();
2879        chunk1.extend_from_slice(&body[..5]);
2880
2881        let out1 = handler.substitute(&chunk1).unwrap();
2882        assert!(out1.is_empty());
2883
2884        let out2 = handler.substitute(&body[5..]).unwrap();
2885        let result = String::from_utf8(out2.into_owned()).unwrap();
2886        let expected_body = "{\"key\":\"longer-secret\"}";
2887        assert!(result.contains(expected_body));
2888        assert!(result.contains(&format!("Content-Length: {}", expected_body.len())));
2889    }
2890
2891    #[test]
2892    fn body_injection_blocks_content_length_over_buffer_limit() {
2893        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2894        secret.injection.body = true;
2895        let config = make_config(vec![secret]);
2896        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2897
2898        let input = format!(
2899            "POST / HTTP/1.1\r\nHost: api.openai.com\r\nContent-Length: {}\r\n\r\n",
2900            MAX_HTTP_BODY_BUFFER_BYTES + 1
2901        );
2902
2903        assert_eq!(
2904            handler.substitute(input.as_bytes()).unwrap_err(),
2905            ViolationAction::Block
2906        );
2907    }
2908
2909    #[test]
2910    fn invalid_content_length_is_blocked() {
2911        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2912        secret.injection.body = true;
2913        let config = make_config(vec![secret]);
2914        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2915
2916        let input =
2917            b"POST / HTTP/1.1\r\nHost: api.openai.com\r\nContent-Length: nope\r\n\r\nxx$KEYyy";
2918
2919        assert_eq!(
2920            handler.substitute(input).unwrap_err(),
2921            ViolationAction::Block
2922        );
2923    }
2924
2925    #[test]
2926    fn conflicting_content_lengths_are_blocked() {
2927        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
2928        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2929
2930        let input = b"POST / HTTP/1.1\r\nHost: api.openai.com\r\nContent-Length: 8\r\nContent-Length: 9\r\n\r\nxx$KEYyy";
2931
2932        assert_eq!(
2933            handler.substitute(input).unwrap_err(),
2934            ViolationAction::Block
2935        );
2936    }
2937
2938    #[test]
2939    fn body_injection_no_content_length_header() {
2940        let mut secret = make_secret("$KEY", "longer-secret", "api.openai.com");
2941        secret.injection.body = true;
2942        let config = make_config(vec![secret]);
2943        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2944
2945        // Chunked requests do not carry Content-Length; body injection
2946        // decodes and re-encodes chunked framing instead.
2947        let input =
2948            b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\nF\r\n{\"key\": \"$KEY\"}\r\n0\r\n\r\n";
2949        let output = handler.substitute(input).unwrap();
2950        let result = String::from_utf8(output.into_owned()).unwrap();
2951        assert!(!result.contains("$KEY"));
2952        assert!(result.contains("longer-secret"));
2953        assert!(!result.contains("Content-Length"));
2954    }
2955
2956    #[test]
2957    fn chunked_body_injection_rewrites_split_placeholder_across_chunks() {
2958        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2959        secret.injection.body = true;
2960        let config = make_config(vec![secret]);
2961        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2962
2963        let input = b"POST / HTTP/1.1\r\nHost: api.openai.com\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nxx$K\r\n2\r\nEY\r\n0\r\n\r\n";
2964        let output = handler.substitute(input).unwrap().into_owned();
2965        let (_, body) = split_http_body(&output);
2966        let (decoded, trailers, consumed) = decode_chunked_payload(body);
2967
2968        assert_eq!(decoded, b"xxreal-secret");
2969        assert_eq!(trailers, b"\r\n");
2970        assert_eq!(consumed, body.len());
2971    }
2972
2973    #[test]
2974    fn chunked_body_injection_rewrites_placeholder_split_across_tls_reads() {
2975        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2976        secret.injection.body = true;
2977        let config = make_config(vec![secret]);
2978        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2979
2980        let chunk1 = b"POST / HTTP/1.1\r\nHost: api.openai.com\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nxx$K\r\n";
2981        let chunk2 = b"2\r\nEY\r\n0\r\n\r\n";
2982
2983        let mut output = handler.substitute(chunk1).unwrap().into_owned();
2984        output.extend_from_slice(handler.substitute(chunk2).unwrap().as_ref());
2985        let (_, body) = split_http_body(&output);
2986        let (decoded, trailers, consumed) = decode_chunked_payload(body);
2987
2988        assert_eq!(decoded, b"xxreal-secret");
2989        assert_eq!(trailers, b"\r\n");
2990        assert_eq!(consumed, body.len());
2991    }
2992
2993    #[test]
2994    fn chunked_body_injection_preserves_trailers_and_recurses_to_next_request() {
2995        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
2996        secret.injection.body = true;
2997        let config = make_config(vec![secret]);
2998        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
2999
3000        let mut input = b"POST /a HTTP/1.1\r\nHost: api.openai.com\r\nTransfer-Encoding: chunked\r\n\r\n4\r\n$KEY\r\n0\r\nX-Trailer: yes\r\n\r\n".to_vec();
3001        input.extend_from_slice(b"GET /b HTTP/1.1\r\nHost: api.openai.com\r\nAuth: $KEY\r\n\r\n");
3002
3003        let output = handler.substitute(&input).unwrap().into_owned();
3004        let (_, body_and_next) = split_http_body(&output);
3005        let (decoded, trailers, consumed) = decode_chunked_payload(body_and_next);
3006        let next_request = &body_and_next[consumed..];
3007
3008        assert_eq!(decoded, b"real-secret");
3009        assert_eq!(trailers, b"X-Trailer: yes\r\n\r\n");
3010        assert_eq!(
3011            next_request,
3012            b"GET /b HTTP/1.1\r\nHost: api.openai.com\r\nAuth: real-secret\r\n\r\n"
3013        );
3014    }
3015
3016    #[test]
3017    fn chunked_body_injection_skips_content_encoded_body() {
3018        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
3019        secret.injection.body = true;
3020        let config = make_config(vec![secret]);
3021        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3022
3023        let input = b"POST / HTTP/1.1\r\nHost: api.openai.com\r\nTransfer-Encoding: chunked\r\nContent-Encoding: gzip\r\n\r\n4\r\n$KEY\r\n0\r\n\r\n";
3024        let output = handler.substitute(input).unwrap();
3025
3026        assert_eq!(output.as_ref(), input);
3027    }
3028
3029    #[test]
3030    fn split_chunked_body_payload_blocks_for_wrong_host() {
3031        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3032        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3033
3034        let input = b"POST / HTTP/1.1\r\nHost: evil.com\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n$K\r\n2\r\nEY\r\n0\r\n\r\n";
3035
3036        assert_eq!(
3037            handler.substitute(input).unwrap_err(),
3038            ViolationAction::Block
3039        );
3040    }
3041
3042    #[test]
3043    fn oversized_secret_placeholder_is_rejected() {
3044        let placeholder = "x".repeat(MAX_SECRET_PLACEHOLDER_BYTES + 1);
3045        let config = make_config(vec![make_secret(
3046            &placeholder,
3047            "real-secret",
3048            "api.openai.com",
3049        )]);
3050        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3051
3052        assert_eq!(
3053            handler.substitute(b"GET / HTTP/1.1\r\n\r\n").unwrap_err(),
3054            ViolationAction::Block
3055        );
3056    }
3057
3058    #[test]
3059    fn header_only_substitution_preserves_content_length() {
3060        let config = make_config(vec![make_secret("$KEY", "longer-value", "api.openai.com")]);
3061        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3062
3063        let input =
3064            b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\nContent-Length: 5\r\n\r\nhello";
3065        let output = handler.substitute(input).unwrap();
3066        let result = String::from_utf8(output.into_owned()).unwrap();
3067        // Body unchanged, Content-Length should stay 5.
3068        assert!(result.contains("Content-Length: 5"));
3069        assert!(result.ends_with("hello"));
3070    }
3071
3072    #[test]
3073    fn eligible_secret_preserves_binary_body_without_placeholder() {
3074        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3075        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3076
3077        let body = vec![0x1f, 0x8b, 0x08, 0x00, 0xff, 0x00, 0x80, 0xfe];
3078        let mut input = format!(
3079            "POST /git-upload-pack HTTP/1.1\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\n\r\n",
3080            body.len()
3081        )
3082        .into_bytes();
3083        input.extend_from_slice(&body);
3084
3085        let output = handler.substitute(&input).unwrap();
3086        assert_eq!(&*output, input.as_slice());
3087    }
3088
3089    #[test]
3090    fn body_injection_skips_content_encoded_body() {
3091        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
3092        secret.injection.body = true;
3093        let config = make_config(vec![secret]);
3094        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3095
3096        let body = b"compressed-looking-$KEY-bytes";
3097        let mut input = format!(
3098            "POST /git-upload-pack HTTP/1.1\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\n\r\n",
3099            body.len()
3100        )
3101        .into_bytes();
3102        input.extend_from_slice(body);
3103
3104        let output = handler.substitute(&input).unwrap();
3105        assert_eq!(&*output, input.as_slice());
3106    }
3107
3108    #[test]
3109    fn eligible_secret_preserves_binary_chunk_without_placeholder() {
3110        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3111        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3112
3113        let input = [0x1f, 0x8b, 0x08, 0x00, 0xff, 0x00, 0x80, 0xfe];
3114        let output = handler.substitute(&input).unwrap();
3115        assert_eq!(&*output, input.as_slice());
3116    }
3117
3118    #[test]
3119    fn body_injection_preserves_non_utf8_bytes() {
3120        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
3121        secret.injection.body = true;
3122        let config = make_config(vec![secret]);
3123        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3124
3125        let body = [0xff, b'$', b'K', b'E', b'Y', 0xfe];
3126        let mut input =
3127            format!("POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes();
3128        input.extend_from_slice(&body);
3129
3130        let output = handler.substitute(&input).unwrap().into_owned();
3131        let expected_body = [b"\xffreal-secret".as_slice(), &[0xfe]].concat();
3132        let expected = [
3133            format!(
3134                "POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n",
3135                expected_body.len()
3136            )
3137            .as_bytes(),
3138            expected_body.as_slice(),
3139        ]
3140        .concat();
3141
3142        assert_eq!(output, expected);
3143    }
3144
3145    #[test]
3146    fn no_secrets_passthrough() {
3147        let config = make_config(vec![]);
3148        let mut handler = SecretsHandler::new(&config, "anything.com", true);
3149
3150        let input = b"GET / HTTP/1.1\r\n\r\n";
3151        let output = handler.substitute(input).unwrap();
3152        assert_eq!(&*output, input);
3153    }
3154
3155    #[test]
3156    fn require_tls_identity_blocks_on_non_intercepted() {
3157        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3158        // tls_intercepted = false — secret requires TLS identity
3159        let mut handler = SecretsHandler::new(&config, "api.openai.com", false);
3160
3161        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
3162        let output = handler.substitute(input).unwrap();
3163        // Placeholder should NOT be substituted.
3164        assert!(
3165            String::from_utf8(output.into_owned())
3166                .unwrap()
3167                .contains("$KEY")
3168        );
3169    }
3170
3171    #[test]
3172    fn basic_auth_only_does_not_substitute_other_schemes() {
3173        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
3174        secret.injection = basic_auth_only();
3175        let config = make_config(vec![secret]);
3176        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3177
3178        // basic_auth only handles Basic credentials; Bearer needs inject_headers.
3179        let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\nX-Custom: $KEY\r\n\r\n";
3180        let output = handler.substitute(input).unwrap();
3181        let result = String::from_utf8(output.into_owned()).unwrap();
3182        assert!(result.contains("Authorization: Bearer $KEY"));
3183        assert!(result.contains("X-Custom: $KEY"));
3184    }
3185
3186    #[test]
3187    fn basic_auth_decodes_substitutes_and_reencodes_credentials() {
3188        let mut user = make_secret("$MSB_USER", "alice", "api.openai.com");
3189        user.env_var = "USER".into();
3190        user.injection = basic_auth_only();
3191        let mut password = make_secret("$MSB_PASSWORD", "s3cr3t", "api.openai.com");
3192        password.env_var = "PASSWORD".into();
3193        password.injection = basic_auth_only();
3194        let config = make_config(vec![user, password]);
3195        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3196
3197        let encoded = BASE64.encode(b"$MSB_USER:$MSB_PASSWORD");
3198        let input = format!("GET / HTTP/1.1\r\nAuthorization: Basic {encoded}\r\n\r\n");
3199        let output = handler.substitute(input.as_bytes()).unwrap();
3200        let result = String::from_utf8(output.into_owned()).unwrap();
3201
3202        assert!(result.contains(&format!(
3203            "Authorization: Basic {}",
3204            BASE64.encode(b"alice:s3cr3t")
3205        )));
3206        assert!(!result.contains("$MSB_USER"));
3207        assert!(!result.contains("$MSB_PASSWORD"));
3208    }
3209
3210    #[test]
3211    fn basic_auth_encoded_placeholder_is_blocked_for_wrong_host() {
3212        let mut secret = make_secret("$MSB_PASSWORD", "s3cr3t", "api.openai.com");
3213        secret.injection = basic_auth_only();
3214        let config = make_config(vec![secret]);
3215        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3216
3217        let encoded = BASE64.encode(b"user:$MSB_PASSWORD");
3218        let input = format!("GET / HTTP/1.1\r\nAuthorization: Basic {encoded}\r\n\r\n");
3219
3220        assert_eq!(
3221            handler.substitute(input.as_bytes()).unwrap_err(),
3222            ViolationAction::Block
3223        );
3224    }
3225
3226    #[test]
3227    fn basic_auth_encoded_placeholder_is_not_replaced_when_scope_disabled() {
3228        let mut secret = make_secret("$MSB_PASSWORD", "s3cr3t", "api.openai.com");
3229        secret.injection = SecretInjection {
3230            headers: false,
3231            basic_auth: false,
3232            query_params: false,
3233            body: false,
3234        };
3235        let config = make_config(vec![secret]);
3236        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3237
3238        let encoded = BASE64.encode(b"user:$MSB_PASSWORD");
3239        let input = format!("GET / HTTP/1.1\r\nAuthorization: Basic {encoded}\r\n\r\n");
3240        let output = handler.substitute(input.as_bytes()).unwrap();
3241
3242        assert_eq!(String::from_utf8(output.into_owned()).unwrap(), input);
3243    }
3244
3245    #[test]
3246    fn query_params_substitution() {
3247        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
3248        secret.injection = SecretInjection {
3249            headers: false,
3250            basic_auth: false,
3251            query_params: true,
3252            body: false,
3253        };
3254        let config = make_config(vec![secret]);
3255        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3256
3257        let input = b"GET /api?key=$KEY HTTP/1.1\r\nHost: api.openai.com\r\n\r\n";
3258        let output = handler.substitute(input).unwrap();
3259        let result = String::from_utf8(output.into_owned()).unwrap();
3260        // Request line should be substituted.
3261        assert!(result.contains("GET /api?key=real-secret HTTP/1.1"));
3262        // Other headers should NOT be substituted.
3263    }
3264
3265    #[test]
3266    fn query_params_do_not_substitute_path() {
3267        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
3268        secret.injection = SecretInjection {
3269            headers: false,
3270            basic_auth: false,
3271            query_params: true,
3272            body: false,
3273        };
3274        let config = make_config(vec![secret]);
3275        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3276
3277        let input = b"GET /path/$KEY?token=$KEY HTTP/1.1\r\nHost: api.openai.com\r\n\r\n";
3278        let output = handler.substitute(input).unwrap();
3279        let result = String::from_utf8(output.into_owned()).unwrap();
3280
3281        assert!(result.contains("GET /path/$KEY?token=real-secret HTTP/1.1"));
3282    }
3283
3284    #[test]
3285    fn header_injection_does_not_substitute_request_line_query() {
3286        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3287        let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
3288
3289        let input = b"GET /api?key=$KEY HTTP/1.1\r\nHost: api.openai.com\r\n\r\n";
3290        let output = handler.substitute(input).unwrap();
3291
3292        assert_eq!(output.as_ref(), input);
3293    }
3294
3295    #[test]
3296    fn url_encoded_placeholder_in_query_blocks_for_wrong_host() {
3297        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3298        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3299
3300        // `%24KEY` is the URL-encoded form of `$KEY`.
3301        let input = b"GET /api?token=%24KEY HTTP/1.1\r\nHost: evil.com\r\n\r\n";
3302        assert_eq!(
3303            handler.substitute(input).unwrap_err(),
3304            ViolationAction::Block
3305        );
3306    }
3307
3308    #[test]
3309    fn url_encoded_placeholder_in_body_blocks_for_wrong_host() {
3310        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3311        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3312
3313        let input = b"POST / HTTP/1.1\r\nContent-Length: 13\r\n\r\nkey=%24KEY&x=1";
3314        assert_eq!(
3315            handler.substitute(input).unwrap_err(),
3316            ViolationAction::Block
3317        );
3318    }
3319
3320    #[test]
3321    fn json_escaped_placeholder_in_body_blocks_for_wrong_host() {
3322        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3323        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3324
3325        // `$KEY` is the JSON unicode-escape form of `$KEY`.
3326        let input =
3327            b"POST / HTTP/1.1\r\nContent-Type: application/json\r\n\r\n{\"k\":\"\\u0024KEY\"}";
3328        assert_eq!(
3329            handler.substitute(input).unwrap_err(),
3330            ViolationAction::Block
3331        );
3332    }
3333
3334    #[test]
3335    fn split_url_encoded_placeholder_blocks_for_wrong_host() {
3336        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3337        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3338
3339        let chunk1 = b"POST / HTTP/1.1\r\nHost: evil.com\r\nContent-Length: 14\r\n\r\nkey=%24K";
3340        let chunk2 = b"EY&x=1";
3341
3342        assert!(handler.substitute(chunk1).is_ok());
3343        assert_eq!(
3344            handler.substitute(chunk2).unwrap_err(),
3345            ViolationAction::Block
3346        );
3347    }
3348
3349    #[test]
3350    fn split_json_escaped_placeholder_blocks_for_wrong_host() {
3351        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3352        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3353
3354        let chunk1 =
3355            b"POST / HTTP/1.1\r\nHost: evil.com\r\nContent-Length: 17\r\n\r\n{\"k\":\"\\u0024K";
3356        let chunk2 = b"EY\"}";
3357
3358        assert!(handler.substitute(chunk1).is_ok());
3359        assert_eq!(
3360            handler.substitute(chunk2).unwrap_err(),
3361            ViolationAction::Block
3362        );
3363    }
3364
3365    #[test]
3366    fn placeholder_split_across_writes_blocks_for_wrong_host() {
3367        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3368        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3369
3370        // Send the placeholder bytes across two separate substitute() calls.
3371        let first = b"GET / HTTP/1.1\r\nX-Token: $K";
3372        let second = b"EY\r\nHost: evil.com\r\n\r\n";
3373
3374        // The first chunk doesn't contain the full placeholder, so it forwards.
3375        assert!(handler.substitute(first).is_ok());
3376        // The second chunk completes the placeholder when stitched with the tail.
3377        assert_eq!(
3378            handler.substitute(second).unwrap_err(),
3379            ViolationAction::Block
3380        );
3381    }
3382
3383    #[test]
3384    fn split_headers_do_not_leak_header_secret_into_body() {
3385        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3386        let mut handler = SecretsHandler::new(&config, "example.com", true);
3387
3388        let chunk1 = b"POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: 8\r\n";
3389        let out1 = handler.substitute(chunk1).unwrap();
3390        assert!(out1.is_empty());
3391
3392        let chunk2 = b"\r\nxx$KEYyy";
3393        let out2 = handler.substitute(chunk2).unwrap();
3394        let result = String::from_utf8(out2.into_owned()).unwrap();
3395
3396        assert!(result.contains("xx$KEYyy"));
3397        assert!(!result.contains("real-secret"));
3398    }
3399
3400    #[test]
3401    fn url_decoded_contains_basic() {
3402        assert!(url_decoded_contains(b"foo%24KEYbar", b"$KEY"));
3403        assert!(!url_decoded_contains(b"fooKEYbar", b"$KEY"));
3404        // Invalid escapes pass through unchanged.
3405        assert!(url_decoded_contains(b"%2", b"%2"));
3406    }
3407
3408    #[test]
3409    fn json_escaped_contains_basic() {
3410        assert!(json_escaped_contains(b"\"\\u0024KEY\"", b"$KEY"));
3411        assert!(json_escaped_contains(
3412            b"\\u0024\\u004B\\u0045\\u0059",
3413            b"$KEY"
3414        ));
3415        assert!(!json_escaped_contains(b"KEY", b"$KEY"));
3416    }
3417
3418    #[test]
3419    fn body_in_separate_chunk_preserves_non_utf8_bytes() {
3420        // substitute() is called once per chunk from the TLS stream. A
3421        // single HTTP request can arrive as (headers) then (body) in
3422        // separate calls; the second call carries body bytes with no
3423        // `\r\n\r\n` boundary and must be recognised as body continuation,
3424        // not parsed as a fresh request.
3425        //
3426        // The body embeds a literal `$KEY` between non-UTF-8 bytes. Without
3427        // framing state the continuation chunk is parsed as headers,
3428        // `may_substitute_in_headers` finds the placeholder, the chunk is
3429        // lossy-decoded (mangling the surrounding bytes), and the
3430        // header-only secret leaks into the body.
3431        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3432        let mut handler = SecretsHandler::new(&config, "example.com", true);
3433
3434        // Chunk 1: headers only; Content-Length announces 13 body bytes.
3435        let chunk1 = b"POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: 13\r\n\r\n";
3436        handler.substitute(chunk1).unwrap();
3437
3438        // Chunk 2: 13 body bytes, no boundary marker. `$KEY` sits between
3439        // 0xff / 0xfe bytes so misclassification corrupts both.
3440        let mut body: Vec<u8> = vec![0x00, 0x80, 0xc0, 0xff, 0xfe];
3441        body.extend_from_slice(b"$KEY");
3442        body.extend_from_slice(&[0x81, 0xc1, 0xee, 0xef]);
3443        assert_eq!(body.len(), 13);
3444
3445        let out = handler.substitute(&body).unwrap();
3446        assert_eq!(out.as_ref(), body.as_slice());
3447    }
3448
3449    #[test]
3450    fn body_split_across_two_chunks_round_trips() {
3451        // Body bytes arrive across two substitute() calls: the first chunk
3452        // carries headers + the first slice of body, the second chunk
3453        // carries the remainder. Both halves must pass through byte-for-byte
3454        // (the state machine decrements `remaining` correctly).
3455        //
3456        // The second chunk embeds a literal `$KEY` between non-UTF-8 bytes,
3457        // so a regression where continuation chunks fall back to the header
3458        // path both leaks the secret and clobbers the surrounding bytes.
3459        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3460        let mut handler = SecretsHandler::new(&config, "example.com", true);
3461
3462        let mut body: Vec<u8> = vec![0x00, 0x80, 0xc0, 0xff, 0xfe, 0xfd, 0xfc];
3463        body.extend_from_slice(b"$KEY");
3464        body.extend_from_slice(&[0x81, 0xc1, 0xee, 0xef]);
3465        assert_eq!(body.len(), 15);
3466
3467        let mut chunk1 =
3468            b"POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: 15\r\n\r\n".to_vec();
3469        chunk1.extend_from_slice(&body[..5]);
3470
3471        let out1 = handler.substitute(&chunk1).unwrap();
3472        let boundary = out1
3473            .windows(4)
3474            .position(|w| w == b"\r\n\r\n")
3475            .map(|p| p + 4)
3476            .unwrap();
3477        assert_eq!(&out1[boundary..], &body[..5]);
3478
3479        let out2 = handler.substitute(&body[5..]).unwrap();
3480        assert_eq!(out2.as_ref(), &body[5..]);
3481    }
3482
3483    #[test]
3484    fn framing_state_resets_after_request_completes() {
3485        // Once a body has been fully forwarded, the next chunk must be
3486        // parsed as a fresh request — not continued as body. A regression
3487        // here would silently treat the next request line as body bytes.
3488        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3489        let mut handler = SecretsHandler::new(&config, "example.com", true);
3490
3491        let body: Vec<u8> = vec![0x00, 0x80, 0xc0, 0xff, 0xfe];
3492        let mut chunk1 =
3493            b"POST /a HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\n".to_vec();
3494        chunk1.extend_from_slice(&body);
3495        handler.substitute(&chunk1).unwrap();
3496
3497        // Second request on the same connection. With state correctly reset
3498        // to AwaitingHeaders, this is parsed normally and forwarded.
3499        let chunk2 = b"GET /b HTTP/1.1\r\nHost: example.com\r\n\r\n";
3500        let out2 = handler.substitute(chunk2).unwrap();
3501        assert_eq!(out2.as_ref(), chunk2.as_slice());
3502    }
3503
3504    #[test]
3505    fn violation_detected_in_body_continuation_chunk() {
3506        // Placeholder bytes for a host that is not allowed to receive the
3507        // real secret arrive in a body-continuation chunk. The body-only
3508        // path must still run violation detection.
3509        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3510        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3511
3512        let chunk1 = b"POST /a HTTP/1.1\r\nHost: evil.com\r\nContent-Length: 16\r\n\r\n";
3513        handler.substitute(chunk1).unwrap();
3514
3515        let chunk2 = b"prefix:$KEY:suffix";
3516        assert_eq!(
3517            handler.substitute(chunk2).unwrap_err(),
3518            ViolationAction::Block
3519        );
3520    }
3521
3522    #[test]
3523    fn header_only_secret_does_not_leak_into_body_continuation_chunk() {
3524        // Security regression: a secret with the default injection scopes
3525        // (inject_headers=true, inject_body=false) must NOT substitute its
3526        // placeholder when the placeholder appears in body bytes. Without
3527        // the framing fix, a body-continuation chunk was parsed as headers
3528        // and run through `substitute_in_headers`, which replaces the
3529        // placeholder on every line — leaking the real secret value into a
3530        // request body the user explicitly opted out of injecting into.
3531        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3532        let mut handler = SecretsHandler::new(&config, "example.com", true);
3533
3534        // Chunk 1: headers only. Content-Length announces 24 body bytes.
3535        let chunk1 = b"POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: 24\r\n\r\n";
3536        handler.substitute(chunk1).unwrap();
3537
3538        // Chunk 2: ASCII body containing a literal `$KEY` token. The
3539        // placeholder must be forwarded verbatim, never replaced with the
3540        // secret value.
3541        let body = b"prefix:$KEY:more-padding";
3542        assert_eq!(body.len(), 24);
3543        let out = handler.substitute(body).unwrap();
3544        assert_eq!(out.as_ref(), body.as_slice());
3545    }
3546
3547    #[test]
3548    fn pipelined_request_in_body_continuation_chunk_is_substituted() {
3549        // HTTP/1.1 pipelining: request 1's body ends partway through chunk
3550        // 2 and request 2's headers follow in the same chunk. Without
3551        // recursion into the spillover, request 2's bytes are forwarded
3552        // verbatim as body and its substitutable placeholder never
3553        // reaches the substitution loop.
3554        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3555        let mut handler = SecretsHandler::new(&config, "example.com", true);
3556
3557        // Chunk 1: request 1 headers + 4 of 5 body bytes.
3558        let mut chunk1 =
3559            b"POST /a HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\n".to_vec();
3560        chunk1.extend_from_slice(b"abcd");
3561        handler.substitute(&chunk1).unwrap();
3562
3563        // Chunk 2: last body byte, then a complete pipelined request with
3564        // `$KEY` in a header.
3565        let mut chunk2 = b"e".to_vec();
3566        chunk2.extend_from_slice(b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\n\r\n");
3567
3568        let out = handler.substitute(&chunk2).unwrap();
3569
3570        let mut expected = b"e".to_vec();
3571        expected.extend_from_slice(
3572            b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\n\r\n",
3573        );
3574        assert_eq!(out.as_ref(), expected.as_slice());
3575    }
3576
3577    #[test]
3578    fn pipelined_request_in_same_chunk_as_headers_is_substituted() {
3579        // Headers-path pipelining: a single chunk carries request 1's
3580        // headers + complete body + the start of request 2. The header
3581        // parser must scope the body to Content-Length and recurse on
3582        // the trailing bytes; otherwise request 2's headers get treated
3583        // as request 1's body and no substitution runs.
3584        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3585        let mut handler = SecretsHandler::new(&config, "example.com", true);
3586
3587        let mut chunk =
3588            b"POST /a HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\n".to_vec();
3589        chunk.extend_from_slice(b"abcde");
3590        chunk.extend_from_slice(b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\n\r\n");
3591
3592        let out = handler.substitute(&chunk).unwrap();
3593
3594        let mut expected =
3595            b"POST /a HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\n".to_vec();
3596        expected.extend_from_slice(b"abcde");
3597        expected.extend_from_slice(
3598            b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\n\r\n",
3599        );
3600        assert_eq!(out.as_ref(), expected.as_slice());
3601    }
3602
3603    #[test]
3604    fn three_pipelined_requests_in_one_chunk_all_substitute() {
3605        // Three pipelined requests in one chunk. The recursion nests
3606        // twice. Each request has a substitutable placeholder in a
3607        // header that must be replaced.
3608        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3609        let mut handler = SecretsHandler::new(&config, "example.com", true);
3610
3611        let r1 =
3612            b"POST /a HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\nContent-Length: 3\r\n\r\nbod";
3613        let r2 =
3614            b"PUT /b HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\nContent-Length: 2\r\n\r\nXY";
3615        let r3 = b"GET /c HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\n\r\n";
3616        let mut chunk = Vec::new();
3617        chunk.extend_from_slice(r1);
3618        chunk.extend_from_slice(r2);
3619        chunk.extend_from_slice(r3);
3620
3621        let out = handler.substitute(&chunk).unwrap();
3622
3623        let r1_out = b"POST /a HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\nContent-Length: 3\r\n\r\nbod";
3624        let r2_out = b"PUT /b HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\nContent-Length: 2\r\n\r\nXY";
3625        let r3_out = b"GET /c HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\n\r\n";
3626        let mut expected = Vec::new();
3627        expected.extend_from_slice(r1_out);
3628        expected.extend_from_slice(r2_out);
3629        expected.extend_from_slice(r3_out);
3630
3631        assert_eq!(out.as_ref(), expected.as_slice());
3632    }
3633
3634    #[test]
3635    fn pipelined_spillover_without_substitution_stays_zero_copy() {
3636        // No eligible secret matches this host; the chunk just needs to
3637        // be forwarded. Even with a pipelined boundary inside the chunk,
3638        // the output should be the original borrowed slice (no allocation).
3639        let config = make_config(vec![make_secret("$KEY", "real-secret", "other.com")]);
3640        let mut handler = SecretsHandler::new(&config, "example.com", true);
3641
3642        let r1 = b"POST /a HTTP/1.1\r\nHost: example.com\r\nContent-Length: 3\r\n\r\nbod";
3643        let r2 = b"GET /b HTTP/1.1\r\nHost: example.com\r\n\r\n";
3644        let mut chunk = Vec::new();
3645        chunk.extend_from_slice(r1);
3646        chunk.extend_from_slice(r2);
3647
3648        let out = handler.substitute(&chunk).unwrap();
3649        assert!(matches!(out, Cow::Borrowed(_)));
3650        assert_eq!(out.as_ref(), chunk.as_slice());
3651    }
3652
3653    #[test]
3654    fn violation_in_pipelined_next_request_basic_auth_is_detected() {
3655        // Request 1's body ends in this chunk and request 2's headers
3656        // follow. Request 2 carries `Authorization: Basic <b64>` whose
3657        // decoded credentials contain a placeholder for a host that is
3658        // NOT allowed to receive the real secret. The base64 form
3659        // has no literal `$KEY` bytes, so the body-path byte scan
3660        // cannot see it. Only the recursive header pass decodes the
3661        // credentials and detects the violation.
3662        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3663        let mut handler = SecretsHandler::new(&config, "evil.com", true);
3664
3665        let chunk1 = b"POST /a HTTP/1.1\r\nHost: evil.com\r\nContent-Length: 3\r\n\r\n";
3666        handler.substitute(chunk1).unwrap();
3667
3668        // base64("admin:$KEY") = "YWRtaW46JEtFWQ==" - no literal `$KEY` in the
3669        // encoded form, so byte-level scanning over the body chunk misses it.
3670        let mut chunk2 = b"foo".to_vec();
3671        chunk2.extend_from_slice(
3672            b"POST /b HTTP/1.1\r\nHost: evil.com\r\nAuthorization: Basic YWRtaW46JEtFWQ==\r\n\r\n",
3673        );
3674        assert_eq!(
3675            handler.substitute(&chunk2).unwrap_err(),
3676            ViolationAction::Block
3677        );
3678    }
3679
3680    #[test]
3681    fn pipelined_get_without_content_length_recurses_into_next_request() {
3682        // Per RFC 9112 §6.3 case 6, a request with no Content-Length and no
3683        // Transfer-Encoding has a zero-length body. Any trailing bytes are
3684        // the start of the next pipelined request, not body of this one.
3685        // A regression that treats them as body misses substitution and
3686        // violation detection for the entire rest of the connection.
3687        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3688        let mut handler = SecretsHandler::new(&config, "example.com", true);
3689
3690        let mut chunk = b"GET /a HTTP/1.1\r\nHost: example.com\r\n\r\n".to_vec();
3691        chunk.extend_from_slice(b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\n\r\n");
3692
3693        let out = handler.substitute(&chunk).unwrap();
3694
3695        let mut expected = b"GET /a HTTP/1.1\r\nHost: example.com\r\n\r\n".to_vec();
3696        expected.extend_from_slice(
3697            b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\n\r\n",
3698        );
3699        assert_eq!(out.as_ref(), expected.as_slice());
3700    }
3701
3702    #[test]
3703    fn substitution_resumes_after_chunked_request_body_terminator() {
3704        // A chunked-encoded request must not poison the connection state.
3705        // After the chunked body terminator (`0\r\n\r\n`), the next bytes
3706        // are the start of a fresh request whose headers must be parsed
3707        // and substituted. A regression that stays in `InBody { None }`
3708        // forever misses every subsequent keep-alive request's headers.
3709        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
3710        let mut handler = SecretsHandler::new(&config, "example.com", true);
3711
3712        // Chunk 1: request 1 headers with `Transfer-Encoding: chunked`.
3713        let chunk1 = b"POST /a HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n";
3714        handler.substitute(chunk1).unwrap();
3715
3716        // Chunk 2: a 5-byte chunk (`hello`), the chunked terminator, then
3717        // a pipelined request with `$KEY` in a header.
3718        let mut chunk2 = b"5\r\nhello\r\n0\r\n\r\n".to_vec();
3719        chunk2.extend_from_slice(b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\n\r\n");
3720
3721        let out = handler.substitute(&chunk2).unwrap();
3722
3723        let mut expected = b"5\r\nhello\r\n0\r\n\r\n".to_vec();
3724        expected.extend_from_slice(
3725            b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\n\r\n",
3726        );
3727        assert_eq!(out.as_ref(), expected.as_slice());
3728    }
3729
3730    #[test]
3731    fn exact_host_requires_dns_pin_for_tls_intercepted_secret() {
3732        let ip = Ipv4Addr::new(203, 0, 113, 10);
3733        let shared = SharedState::new(16);
3734        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3735        let mut handler =
3736            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3737
3738        let input = b"GET / HTTP/1.1\r\nHost: api.openai.com\r\nAuthorization: Bearer $KEY\r\n\r\n";
3739        assert_eq!(
3740            handler.substitute(input).unwrap_err(),
3741            ViolationAction::Block
3742        );
3743
3744        cache_host(&shared, "api.openai.com", ip);
3745        let mut handler =
3746            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3747        let output = handler.substitute(input).unwrap();
3748
3749        assert!(
3750            String::from_utf8(output.into_owned())
3751                .unwrap()
3752                .contains("real-secret")
3753        );
3754    }
3755
3756    #[test]
3757    fn any_host_bypasses_dns_pin_for_tls_intercepted_secret() {
3758        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
3759        secret.allowed_hosts = vec![HostPattern::Any];
3760        let config = make_config(vec![secret]);
3761        let shared = SharedState::new(16);
3762        let mut handler = SecretsHandler::new_tls_intercepted(
3763            &config,
3764            "unresolved.example",
3765            IpAddr::V4(Ipv4Addr::new(203, 0, 113, 20)),
3766            &shared,
3767        );
3768
3769        let input =
3770            b"GET / HTTP/1.1\r\nHost: unresolved.example\r\nAuthorization: Bearer $KEY\r\n\r\n";
3771        let output = handler.substitute(input).unwrap();
3772
3773        assert!(
3774            String::from_utf8(output.into_owned())
3775                .unwrap()
3776                .contains("real-secret")
3777        );
3778    }
3779
3780    #[test]
3781    fn host_alias_matches_gateway_without_dns_pin() {
3782        let gateway = Ipv4Addr::new(192, 0, 2, 1);
3783        let shared = SharedState::new(16);
3784        shared.set_gateway_ips(Some(gateway), None);
3785
3786        let config = make_config(vec![make_secret("$KEY", "real-secret", crate::HOST_ALIAS)]);
3787        let mut handler = SecretsHandler::new_tls_intercepted(
3788            &config,
3789            crate::HOST_ALIAS,
3790            IpAddr::V4(gateway),
3791            &shared,
3792        );
3793
3794        let input = format!(
3795            "GET / HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer $KEY\r\n\r\n",
3796            crate::HOST_ALIAS
3797        );
3798        let output = handler.substitute(input.as_bytes()).unwrap();
3799
3800        assert!(
3801            String::from_utf8(output.into_owned())
3802                .unwrap()
3803                .contains("real-secret")
3804        );
3805    }
3806
3807    #[test]
3808    fn tls_intercepted_http_host_must_match_sni() {
3809        let ip = Ipv4Addr::new(203, 0, 113, 30);
3810        let shared = SharedState::new(16);
3811        cache_host(&shared, "api.openai.com", ip);
3812        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3813        let mut handler =
3814            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3815
3816        let input = b"GET / HTTP/1.1\r\nHost: evil.com\r\nAuthorization: Bearer $KEY\r\n\r\n";
3817        assert_eq!(
3818            handler.substitute(input).unwrap_err(),
3819            ViolationAction::Block
3820        );
3821    }
3822
3823    #[test]
3824    fn tls_intercepted_http_host_validation_buffers_split_headers() {
3825        let ip = Ipv4Addr::new(203, 0, 113, 31);
3826        let shared = SharedState::new(16);
3827        cache_host(&shared, "api.openai.com", ip);
3828        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3829        let mut handler =
3830            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3831
3832        let out1 = handler
3833            .substitute(b"GET / HTTP/1.1\r\nHost: evil.com\r\n")
3834            .unwrap();
3835        assert!(out1.is_empty());
3836        assert_eq!(
3837            handler
3838                .substitute(b"Authorization: Bearer $KEY\r\n\r\n")
3839                .unwrap_err(),
3840            ViolationAction::Block
3841        );
3842    }
3843
3844    #[test]
3845    fn tls_intercepted_http_host_validation_survives_leading_empty_block() {
3846        let ip = Ipv4Addr::new(203, 0, 113, 32);
3847        let shared = SharedState::new(16);
3848        cache_host(&shared, "api.openai.com", ip);
3849        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3850        let mut handler =
3851            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3852
3853        assert_eq!(
3854            handler.substitute(b"\r\n\r\n").unwrap().as_ref(),
3855            b"\r\n\r\n"
3856        );
3857        assert_eq!(
3858            handler
3859                .substitute(b"GET / HTTP/1.1\r\nHost: evil.com\r\nAuth: $KEY\r\n\r\n")
3860                .unwrap_err(),
3861            ViolationAction::Block
3862        );
3863    }
3864
3865    #[test]
3866    fn tls_intercepted_http2_authority_must_match_sni() {
3867        let ip = Ipv4Addr::new(203, 0, 113, 33);
3868        let shared = SharedState::new(16);
3869        cache_host(&shared, "api.openai.com", ip);
3870        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3871        let mut handler =
3872            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3873
3874        let request = h2_request(
3875            &[
3876                (b":method", b"GET"),
3877                (b":scheme", b"https"),
3878                (b":authority", b"evil.com"),
3879                (b":path", b"/"),
3880                (b"authorization", b"Bearer $KEY"),
3881            ],
3882            true,
3883        );
3884
3885        assert_eq!(
3886            handler.substitute(&request).unwrap_err(),
3887            ViolationAction::Block
3888        );
3889    }
3890
3891    #[test]
3892    fn tls_intercepted_http2_substitutes_header_secret() {
3893        let ip = Ipv4Addr::new(203, 0, 113, 34);
3894        let shared = SharedState::new(16);
3895        cache_host(&shared, "api.openai.com", ip);
3896        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3897        let mut handler =
3898            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3899
3900        let request = h2_request(
3901            &[
3902                (b":method", b"GET"),
3903                (b":scheme", b"https"),
3904                (b":authority", b"api.openai.com"),
3905                (b":path", b"/"),
3906                (b"authorization", b"Bearer $KEY"),
3907            ],
3908            true,
3909        );
3910
3911        let output = handler.substitute(&request).unwrap().into_owned();
3912        let headers = decode_first_h2_headers(&output);
3913        assert_eq!(
3914            h2_header_value(&headers, b"authorization"),
3915            "Bearer real-secret"
3916        );
3917    }
3918
3919    #[test]
3920    fn tls_intercepted_http2_preface_can_span_tls_reads() {
3921        let ip = Ipv4Addr::new(203, 0, 113, 38);
3922        let shared = SharedState::new(16);
3923        cache_host(&shared, "api.openai.com", ip);
3924        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3925        let mut handler =
3926            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3927
3928        let request = h2_request(
3929            &[
3930                (b":method", b"GET"),
3931                (b":scheme", b"https"),
3932                (b":authority", b"api.openai.com"),
3933                (b":path", b"/"),
3934                (b"authorization", b"Bearer $KEY"),
3935            ],
3936            true,
3937        );
3938
3939        assert_eq!(handler.substitute(&request[..1]).unwrap().as_ref(), b"");
3940
3941        let output = handler.substitute(&request[1..]).unwrap().into_owned();
3942        let headers = decode_first_h2_headers(&output);
3943        assert_eq!(
3944            h2_header_value(&headers, b"authorization"),
3945            "Bearer real-secret"
3946        );
3947    }
3948
3949    #[test]
3950    fn tls_intercepted_http2_substitutes_query_and_basic_auth() {
3951        let ip = Ipv4Addr::new(203, 0, 113, 35);
3952        let shared = SharedState::new(16);
3953        cache_host(&shared, "api.openai.com", ip);
3954        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
3955        secret.injection = SecretInjection {
3956            headers: false,
3957            basic_auth: true,
3958            query_params: true,
3959            body: false,
3960        };
3961        let config = make_config(vec![secret]);
3962        let mut handler =
3963            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3964        let auth = format!("Basic {}", BASE64.encode(b"user:$KEY"));
3965
3966        let request = h2_request(
3967            &[
3968                (b":method", b"GET"),
3969                (b":scheme", b"https"),
3970                (b":authority", b"api.openai.com"),
3971                (b":path", b"/v1/$KEY?token=$KEY"),
3972                (b"authorization", auth.as_bytes()),
3973            ],
3974            true,
3975        );
3976
3977        let output = handler.substitute(&request).unwrap().into_owned();
3978        let headers = decode_first_h2_headers(&output);
3979        assert_eq!(
3980            h2_header_value(&headers, b":path"),
3981            "/v1/$KEY?token=real-secret"
3982        );
3983        let auth = h2_header_value(&headers, b"authorization");
3984        let decoded = split_auth_scheme(&auth)
3985            .and_then(|(_, encoded)| BASE64.decode(encoded).ok())
3986            .and_then(|bytes| String::from_utf8(bytes).ok())
3987            .unwrap();
3988        assert_eq!(decoded, "user:real-secret");
3989    }
3990
3991    #[test]
3992    fn tls_intercepted_http2_split_header_block_is_validated() {
3993        let ip = Ipv4Addr::new(203, 0, 113, 36);
3994        let shared = SharedState::new(16);
3995        cache_host(&shared, "api.openai.com", ip);
3996        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
3997        let mut handler =
3998            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
3999
4000        let request = h2_request_with_split_headers(
4001            &[
4002                (b":method", b"GET"),
4003                (b":scheme", b"https"),
4004                (b":authority", b"evil.com"),
4005                (b":path", b"/"),
4006                (b"authorization", b"Bearer $KEY"),
4007            ],
4008            8,
4009        );
4010
4011        assert_eq!(
4012            handler.substitute(&request).unwrap_err(),
4013            ViolationAction::Block
4014        );
4015    }
4016
4017    #[test]
4018    fn tls_intercepted_http2_body_placeholder_blocks_until_body_rewrite_exists() {
4019        let ip = Ipv4Addr::new(203, 0, 113, 37);
4020        let shared = SharedState::new(16);
4021        cache_host(&shared, "api.openai.com", ip);
4022        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
4023        secret.injection.body = true;
4024        let config = make_config(vec![secret]);
4025        let mut handler =
4026            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
4027
4028        let request = h2_request_with_data(
4029            &[
4030                (b":method", b"POST"),
4031                (b":scheme", b"https"),
4032                (b":authority", b"api.openai.com"),
4033                (b":path", b"/"),
4034            ],
4035            b"{\"key\":\"$KEY\"}",
4036        );
4037
4038        assert_eq!(
4039            handler.substitute(&request).unwrap_err(),
4040            ViolationAction::Block
4041        );
4042    }
4043
4044    #[test]
4045    fn tls_intercepted_http2_body_placeholder_split_across_data_frames_blocks() {
4046        let ip = Ipv4Addr::new(203, 0, 113, 39);
4047        let shared = SharedState::new(16);
4048        cache_host(&shared, "api.openai.com", ip);
4049        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
4050        secret.injection.body = true;
4051        let config = make_config(vec![secret]);
4052        let mut handler =
4053            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
4054
4055        let mut request = HTTP2_PREFACE.to_vec();
4056        append_http2_frame(&mut request, 0x4, 0, 0, &[]).unwrap();
4057        append_h2_headers(
4058            &mut request,
4059            1,
4060            &[
4061                (b":method", b"POST"),
4062                (b":scheme", b"https"),
4063                (b":authority", b"api.openai.com"),
4064                (b":path", b"/"),
4065            ],
4066            false,
4067        );
4068        append_http2_frame(&mut request, HTTP2_FRAME_DATA, 0, 1, b"$KE").unwrap();
4069        append_http2_frame(
4070            &mut request,
4071            HTTP2_FRAME_DATA,
4072            HTTP2_FLAG_END_STREAM,
4073            1,
4074            b"Y",
4075        )
4076        .unwrap();
4077
4078        assert_eq!(
4079            handler.substitute(&request).unwrap_err(),
4080            ViolationAction::Block
4081        );
4082    }
4083
4084    #[test]
4085    fn tls_intercepted_http2_data_tails_are_tracked_per_stream() {
4086        let ip = Ipv4Addr::new(203, 0, 113, 40);
4087        let shared = SharedState::new(16);
4088        cache_host(&shared, "api.openai.com", ip);
4089        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
4090        secret.injection.body = true;
4091        let config = make_config(vec![secret]);
4092        let mut handler =
4093            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
4094
4095        let mut request = HTTP2_PREFACE.to_vec();
4096        append_http2_frame(&mut request, 0x4, 0, 0, &[]).unwrap();
4097        for stream_id in [1, 3] {
4098            append_h2_headers(
4099                &mut request,
4100                stream_id,
4101                &[
4102                    (b":method", b"POST"),
4103                    (b":scheme", b"https"),
4104                    (b":authority", b"api.openai.com"),
4105                    (b":path", b"/"),
4106                ],
4107                false,
4108            );
4109        }
4110        append_http2_frame(&mut request, HTTP2_FRAME_DATA, 0, 1, b"$KE").unwrap();
4111        append_http2_frame(
4112            &mut request,
4113            HTTP2_FRAME_DATA,
4114            HTTP2_FLAG_END_STREAM,
4115            3,
4116            b"Y",
4117        )
4118        .unwrap();
4119
4120        assert!(handler.substitute(&request).is_ok());
4121    }
4122
4123    #[test]
4124    fn tls_intercepted_http2_large_data_frame_without_placeholder_passes() {
4125        let ip = Ipv4Addr::new(203, 0, 113, 41);
4126        let shared = SharedState::new(16);
4127        cache_host(&shared, "api.openai.com", ip);
4128        let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
4129        secret.injection.body = true;
4130        let config = make_config(vec![secret]);
4131        let mut handler =
4132            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
4133        let payload = vec![b'a'; 1024 * 1024];
4134
4135        let request = h2_request_with_data(
4136            &[
4137                (b":method", b"POST"),
4138                (b":scheme", b"https"),
4139                (b":authority", b"api.openai.com"),
4140                (b":path", b"/"),
4141            ],
4142            &payload,
4143        );
4144
4145        let output = handler.substitute(&request).unwrap().into_owned();
4146        assert!(output.ends_with(&payload));
4147    }
4148
4149    #[test]
4150    fn tls_intercepted_http2_data_before_headers_is_blocked() {
4151        let ip = Ipv4Addr::new(203, 0, 113, 42);
4152        let shared = SharedState::new(16);
4153        cache_host(&shared, "api.openai.com", ip);
4154        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
4155        let mut handler =
4156            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
4157
4158        let mut request = HTTP2_PREFACE.to_vec();
4159        append_http2_frame(&mut request, 0x4, 0, 0, &[]).unwrap();
4160        append_http2_frame(
4161            &mut request,
4162            HTTP2_FRAME_DATA,
4163            HTTP2_FLAG_END_STREAM,
4164            1,
4165            b"body",
4166        )
4167        .unwrap();
4168
4169        assert_eq!(
4170            handler.substitute(&request).unwrap_err(),
4171            ViolationAction::Block
4172        );
4173    }
4174
4175    #[test]
4176    fn tls_intercepted_http2_decoded_header_list_size_is_bounded() {
4177        let ip = Ipv4Addr::new(203, 0, 113, 43);
4178        let shared = SharedState::new(16);
4179        cache_host(&shared, "api.openai.com", ip);
4180        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
4181        let mut handler =
4182            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
4183        let mut encoder = HpackEncoder::with_dynamic_size(4096);
4184
4185        let mut first_block = Vec::new();
4186        for (name, value) in [
4187            (b":method".as_slice(), b"GET".as_slice()),
4188            (b":scheme".as_slice(), b"https".as_slice()),
4189            (b":authority".as_slice(), b"api.openai.com".as_slice()),
4190            (b":path".as_slice(), b"/".as_slice()),
4191        ] {
4192            encoder
4193                .encode(
4194                    (name.to_vec(), value.to_vec(), HpackEncoder::NEVER_INDEXED),
4195                    &mut first_block,
4196                )
4197                .unwrap();
4198        }
4199        encoder
4200            .encode(
4201                (
4202                    b"x-fill".to_vec(),
4203                    vec![b'a'; 4000],
4204                    HpackEncoder::WITH_INDEXING,
4205                ),
4206                &mut first_block,
4207            )
4208            .unwrap();
4209
4210        let mut second_block = Vec::new();
4211        for (name, value) in [
4212            (b":method".as_slice(), b"GET".as_slice()),
4213            (b":scheme".as_slice(), b"https".as_slice()),
4214            (b":authority".as_slice(), b"api.openai.com".as_slice()),
4215            (b":path".as_slice(), b"/".as_slice()),
4216        ] {
4217            encoder
4218                .encode(
4219                    (name.to_vec(), value.to_vec(), HpackEncoder::NEVER_INDEXED),
4220                    &mut second_block,
4221                )
4222                .unwrap();
4223        }
4224        for _ in 0..20 {
4225            encoder.encode(62u32, &mut second_block).unwrap();
4226        }
4227
4228        let mut request = HTTP2_PREFACE.to_vec();
4229        append_http2_frame(&mut request, 0x4, 0, 0, &[]).unwrap();
4230        append_http2_header_frames(&mut request, 1, true, &first_block).unwrap();
4231        append_http2_header_frames(&mut request, 3, true, &second_block).unwrap();
4232
4233        assert_eq!(
4234            handler.substitute(&request).unwrap_err(),
4235            ViolationAction::Block
4236        );
4237    }
4238
4239    #[test]
4240    fn tls_intercepted_http2_limits_concurrent_open_streams() {
4241        let ip = Ipv4Addr::new(203, 0, 113, 44);
4242        let shared = SharedState::new(16);
4243        cache_host(&shared, "api.openai.com", ip);
4244        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
4245        let mut handler =
4246            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
4247
4248        let mut request = HTTP2_PREFACE.to_vec();
4249        append_http2_frame(&mut request, 0x4, 0, 0, &[]).unwrap();
4250        for i in 0..=MAX_HTTP2_TRACKED_STREAMS {
4251            append_h2_headers(
4252                &mut request,
4253                1 + (i as u32 * 2),
4254                &[
4255                    (b":method", b"POST"),
4256                    (b":scheme", b"https"),
4257                    (b":authority", b"api.openai.com"),
4258                    (b":path", b"/"),
4259                ],
4260                false,
4261            );
4262        }
4263
4264        assert_eq!(
4265            handler.substitute(&request).unwrap_err(),
4266            ViolationAction::Block
4267        );
4268    }
4269
4270    #[test]
4271    fn tls_intercepted_http2_closed_streams_release_tracking_state() {
4272        let ip = Ipv4Addr::new(203, 0, 113, 45);
4273        let shared = SharedState::new(16);
4274        cache_host(&shared, "api.openai.com", ip);
4275        let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
4276        let mut handler =
4277            SecretsHandler::new_tls_intercepted(&config, "api.openai.com", IpAddr::V4(ip), &shared);
4278
4279        let mut request = HTTP2_PREFACE.to_vec();
4280        append_http2_frame(&mut request, 0x4, 0, 0, &[]).unwrap();
4281        for i in 0..=MAX_HTTP2_TRACKED_STREAMS {
4282            append_h2_headers(
4283                &mut request,
4284                1 + (i as u32 * 2),
4285                &[
4286                    (b":method", b"GET"),
4287                    (b":scheme", b"https"),
4288                    (b":authority", b"api.openai.com"),
4289                    (b":path", b"/"),
4290                ],
4291                true,
4292            );
4293        }
4294
4295        assert!(handler.substitute(&request).is_ok());
4296    }
4297
4298    #[test]
4299    fn chunked_body_internal_terminator_bytes_do_not_end_request() {
4300        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
4301        let mut handler = SecretsHandler::new(&config, "example.com", true);
4302
4303        let chunk1 = b"POST /a HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n";
4304        handler.substitute(chunk1).unwrap();
4305
4306        let mut chunk2 = b"B\r\nAA\r\n0\r\n\r\nBB\r\n0\r\n\r\n".to_vec();
4307        chunk2.extend_from_slice(b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\n\r\n");
4308
4309        let out = handler.substitute(&chunk2).unwrap();
4310
4311        let mut expected = b"B\r\nAA\r\n0\r\n\r\nBB\r\n0\r\n\r\n".to_vec();
4312        expected.extend_from_slice(
4313            b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\n\r\n",
4314        );
4315        assert_eq!(out.as_ref(), expected.as_slice());
4316    }
4317
4318    #[test]
4319    fn split_chunked_terminator_resumes_next_request() {
4320        let config = make_config(vec![make_secret("$KEY", "real-secret", "example.com")]);
4321        let mut handler = SecretsHandler::new(&config, "example.com", true);
4322
4323        let chunk1 = b"POST /a HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n";
4324        handler.substitute(chunk1).unwrap();
4325
4326        let chunk2 = b"5\r\nhello\r\n0\r";
4327        let out2 = handler.substitute(chunk2).unwrap();
4328        assert_eq!(out2.as_ref(), chunk2.as_slice());
4329
4330        let mut chunk3 = b"\n\r\n".to_vec();
4331        chunk3.extend_from_slice(b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: $KEY\r\n\r\n");
4332
4333        let out3 = handler.substitute(&chunk3).unwrap();
4334
4335        let mut expected = b"\n\r\n".to_vec();
4336        expected.extend_from_slice(
4337            b"GET /b HTTP/1.1\r\nHost: example.com\r\nAuth: real-secret\r\n\r\n",
4338        );
4339        assert_eq!(out3.as_ref(), expected.as_slice());
4340    }
4341}