Skip to main content

microsandbox_network/secrets/
handler.rs

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