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