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