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