Skip to main content

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