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