Skip to main content

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