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