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