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