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