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