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