Skip to main content

microsandbox_network/secrets/
handler.rs

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