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