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