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