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