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