Skip to main content

microsandbox_network/engine/secrets/
handler.rs

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