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