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