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