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