1use std::borrow::Cow;
9use std::io;
10use std::net::{IpAddr, SocketAddr};
11use std::sync::Arc;
12use std::time::Duration;
13
14use bytes::Bytes;
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tokio::net::TcpStream;
17use tokio::sync::mpsc;
18
19use crate::conn::ProxyConnectState;
20use crate::policy::{EgressEvaluation, HostnameSource, NetworkPolicy, Protocol};
21use crate::secrets::config::{SecretsConfig, SecretsConfigExt, ViolationAction};
22use crate::secrets::handler::{
23 SecretsHandler, first_line_is_not_http_request, looks_like_http_request_prefix,
24};
25use crate::shared::SharedState;
26use crate::tls::proxy::{TlsProxyContext, tls_proxy_task};
27use crate::tls::sni;
28use crate::tls::state::TlsState;
29
30const SERVER_READ_BUF_SIZE: usize = 16384;
36
37const CONNECT_RESP_LIMIT: usize = 8192;
39
40const PEEK_BUF_SIZE: usize = 16384;
42
43const PEEK_BUDGET: Duration = Duration::from_secs(5);
46
47#[derive(Debug)]
52struct ConnectRequest {
53 bytes: Vec<u8>,
54 header_end: usize,
55 target: ConnectTarget,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59struct ConnectTarget {
60 host: String,
61 port: u16,
62 expected_sni: Option<String>,
63}
64
65impl ConnectRequest {
70 fn header_bytes(&self) -> &[u8] {
71 &self.bytes[..self.header_end]
72 }
73
74 fn post_header_bytes(&self) -> &[u8] {
75 &self.bytes[self.header_end..]
76 }
77}
78
79impl ConnectTarget {
80 fn is_intercepted(&self, tls_state: &TlsState) -> bool {
81 tls_state.config.intercepted_ports.contains(&self.port)
82 }
83
84 fn guest_dst(&self, fallback: SocketAddr, shared: &SharedState) -> SocketAddr {
85 if let Ok(ip) = self.host.parse::<IpAddr>() {
86 return SocketAddr::new(ip, self.port);
87 }
88
89 if self.host.eq_ignore_ascii_case(crate::HOST_ALIAS) {
90 match fallback.ip() {
91 IpAddr::V4(_) => {
92 if let Some(ip) = shared.gateway_ipv4() {
93 return SocketAddr::new(IpAddr::V4(ip), self.port);
94 }
95 }
96 IpAddr::V6(_) => {
97 if let Some(ip) = shared.gateway_ipv6() {
98 return SocketAddr::new(IpAddr::V6(ip), self.port);
99 }
100 }
101 }
102 if let Some(ip) = shared.gateway_ipv4() {
103 return SocketAddr::new(IpAddr::V4(ip), self.port);
104 }
105 if let Some(ip) = shared.gateway_ipv6() {
106 return SocketAddr::new(IpAddr::V6(ip), self.port);
107 }
108 }
109
110 SocketAddr::new(fallback.ip(), self.port)
111 }
112}
113
114pub(crate) async fn connect_upstream(
120 dst: SocketAddr,
121 proxy_connect: &ProxyConnectState,
122 shared: &SharedState,
123) -> io::Result<TcpStream> {
124 match TcpStream::connect(dst).await {
125 Ok(s) => {
126 proxy_connect.mark_connected();
127 Ok(s)
128 }
129 Err(e) => {
130 proxy_connect.mark_upstream_connect_failed();
131 shared.proxy_wake.wake();
132 Err(e)
133 }
134 }
135}
136
137#[allow(clippy::too_many_arguments)]
148pub fn spawn_tcp_proxy(
149 handle: &tokio::runtime::Handle,
150 guest_dst: SocketAddr,
151 connect_dst: SocketAddr,
152 from_smoltcp: mpsc::Receiver<Bytes>,
153 to_smoltcp: mpsc::Sender<Bytes>,
154 shared: Arc<SharedState>,
155 network_policy: Arc<NetworkPolicy>,
156 secrets: Arc<SecretsConfig>,
157 tls_state: Option<Arc<TlsState>>,
158 proxy_connect: Arc<ProxyConnectState>,
159) {
160 handle.spawn(async move {
161 if let Err(e) = tcp_proxy_task(
162 guest_dst,
163 connect_dst,
164 from_smoltcp,
165 to_smoltcp,
166 shared,
167 network_policy,
168 secrets,
169 tls_state,
170 proxy_connect,
171 )
172 .await
173 {
174 tracing::debug!(dst = %connect_dst, error = %e, "TCP proxy task ended");
175 }
176 });
177}
178
179#[allow(clippy::too_many_arguments)]
182async fn tcp_proxy_task(
183 guest_dst: SocketAddr,
184 connect_dst: SocketAddr,
185 mut from_smoltcp: mpsc::Receiver<Bytes>,
186 to_smoltcp: mpsc::Sender<Bytes>,
187 shared: Arc<SharedState>,
188 network_policy: Arc<NetworkPolicy>,
189 secrets: Arc<SecretsConfig>,
190 tls_state: Option<Arc<TlsState>>,
191 proxy_connect: Arc<ProxyConnectState>,
192) -> io::Result<()> {
193 let (mut initial_buf, sni) = if network_policy.has_domain_rules() {
199 peek_for_sni(&mut from_smoltcp, PEEK_BUF_SIZE, PEEK_BUDGET).await
200 } else {
201 (Vec::new(), None)
202 };
203
204 if network_policy.has_domain_rules() {
210 let source = match sni.as_deref() {
211 Some(name) => HostnameSource::Sni(name),
212 None => HostnameSource::CacheOnly,
213 };
214 match network_policy.evaluate_egress_with_source(guest_dst, Protocol::Tcp, &shared, source)
215 {
216 EgressEvaluation::Allow => {}
217 EgressEvaluation::Deny => {
218 tracing::debug!(
219 dst = %guest_dst,
220 source = source.label(),
221 "TCP egress denied by domain policy",
222 );
223 proxy_connect.mark_policy_denied();
224 shared.proxy_wake.wake();
225 return Ok(());
226 }
227 EgressEvaluation::DeferUntilHostname => {
228 debug_assert!(false, "DeferUntilHostname leaked into TCP proxy task");
229 proxy_connect.mark_policy_denied();
230 shared.proxy_wake.wake();
231 return Ok(());
232 }
233 }
234 }
235
236 if let Some(tls_state) = tls_state.clone() {
238 if initial_buf.is_empty() {
239 let (peeked, _) = peek_for_sni(&mut from_smoltcp, PEEK_BUF_SIZE, PEEK_BUDGET).await;
240 initial_buf = peeked;
241 }
242 if could_be_connect_request(&initial_buf) {
243 return handle_connect_tunnel(
244 guest_dst,
245 connect_dst,
246 initial_buf,
247 from_smoltcp,
248 to_smoltcp,
249 shared,
250 network_policy,
251 tls_state,
252 proxy_connect,
253 None,
254 )
255 .await;
256 }
257 }
258
259 let stream = connect_upstream(connect_dst, &proxy_connect, &shared).await?;
264 let (mut server_rx, mut server_tx) = stream.into_split();
265
266 let want_headers = secrets.has_plain_http_candidates() || secrets.has_host_scoped_secrets();
272 let (initial_buf, is_tls) = if !secrets.secrets.is_empty() {
273 classify_first_flight(
274 initial_buf,
275 &mut from_smoltcp,
276 &mut server_rx,
277 &to_smoltcp,
278 &shared,
279 want_headers,
280 PEEK_BUF_SIZE,
281 PEEK_BUDGET,
282 )
283 .await?
284 } else {
285 (initial_buf, false)
286 };
287
288 if let Some(tls_state) = tls_state.clone()
289 && could_be_connect_request(&initial_buf)
290 {
291 let proxy_stream = server_rx
296 .reunite(server_tx)
297 .map_err(|_| io::Error::other("failed to reunite proxy stream halves"))?;
298 return handle_connect_tunnel(
299 guest_dst,
300 connect_dst,
301 initial_buf,
302 from_smoltcp,
303 to_smoltcp,
304 shared,
305 network_policy,
306 tls_state,
307 proxy_connect,
308 Some(proxy_stream),
309 )
310 .await;
311 }
312
313 let mut late_connect_state = tls_state;
314 let mut secrets_handler: Option<SecretsHandler> = if !secrets.secrets.is_empty() && !is_tls {
315 Some(match extract_http_host(&initial_buf) {
316 Some(host) => SecretsHandler::new_plain_http(&secrets, &host, guest_dst.ip(), &shared),
317 None => SecretsHandler::new_plain_http_invalid_host(&secrets),
318 })
319 } else {
320 None
321 };
322
323 if !initial_buf.is_empty() {
325 let out: Cow<[u8]> = match secrets_handler.as_mut() {
326 Some(h) => match h.substitute(&initial_buf) {
327 Ok(cow) => cow,
330 Err(action) => {
331 tracing::warn!(dst = %connect_dst, violation = ?action, "secret violation in first flight");
332 if matches!(action, ViolationAction::BlockAndTerminate) {
333 shared.trigger_termination();
334 }
335 return Ok(());
336 }
337 },
338 None => Cow::Borrowed(&initial_buf),
339 };
340 if !out.is_empty() {
341 if let Err(e) = server_tx.write_all(&out).await {
342 tracing::debug!(dst = %connect_dst, error = %e, "replay of buffered first flight failed");
343 return Ok(());
344 }
345 if let Err(e) = server_tx.flush().await {
346 tracing::debug!(dst = %connect_dst, error = %e, "flush after first flight failed");
347 return Ok(());
348 }
349 }
350 }
351
352 let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
353
354 let mut guest_eof = false;
359 loop {
360 tokio::select! {
361 data = from_smoltcp.recv(), if !guest_eof => {
363 match data {
364 Some(bytes) => {
365 if let Some(tls_state) = late_connect_state.take()
366 && could_be_connect_request(&bytes)
367 {
368 let proxy_stream = server_rx
373 .reunite(server_tx)
374 .map_err(|_| io::Error::other("failed to reunite proxy stream halves"))?;
375 return handle_connect_tunnel(
376 guest_dst,
377 connect_dst,
378 bytes.to_vec(),
379 from_smoltcp,
380 to_smoltcp,
381 shared,
382 network_policy,
383 tls_state,
384 proxy_connect,
385 Some(proxy_stream),
386 )
387 .await;
388 }
389 let out: Cow<[u8]> = match secrets_handler.as_mut() {
392 Some(h) => match h.substitute(&bytes) {
393 Ok(cow) => cow,
394 Err(action) => {
395 tracing::warn!(dst = %connect_dst, violation = ?action, "secret violation");
396 if matches!(action, ViolationAction::BlockAndTerminate) {
397 shared.trigger_termination();
398 }
399 break;
400 }
401 },
402 None => Cow::Borrowed(&bytes),
403 };
404 if !out.is_empty() {
405 if let Err(e) = server_tx.write_all(&out).await {
406 tracing::debug!(dst = %connect_dst, error = %e, "write to server failed");
407 break;
408 }
409 if let Err(e) = server_tx.flush().await {
410 tracing::debug!(dst = %connect_dst, error = %e, "flush to server failed");
411 break;
412 }
413 }
414 }
415 None => {
420 guest_eof = true;
421 if server_tx.shutdown().await.is_err() {
422 break;
423 }
424 }
425 }
426 }
427
428 result = server_rx.read(&mut server_buf) => {
430 match result {
431 Ok(0) => break, Ok(n) => {
433 late_connect_state = None;
436 let data = Bytes::copy_from_slice(&server_buf[..n]);
437 if to_smoltcp.send(data).await.is_err() {
438 break;
440 }
441 shared.proxy_wake.wake();
444 }
445 Err(e) => {
446 tracing::debug!(dst = %connect_dst, error = %e, "read from server failed");
447 break;
448 }
449 }
450 }
451 }
452 }
453
454 Ok(())
455}
456
457#[allow(clippy::too_many_arguments)]
463async fn handle_connect_tunnel(
464 guest_dst: SocketAddr,
465 proxy_dst: SocketAddr,
466 initial_buf: Vec<u8>,
467 mut from_smoltcp: mpsc::Receiver<Bytes>,
468 to_smoltcp: mpsc::Sender<Bytes>,
469 shared: Arc<SharedState>,
470 network_policy: Arc<NetworkPolicy>,
471 tls_state: Arc<TlsState>,
472 proxy_connect: Arc<ProxyConnectState>,
473 preconnected_proxy: Option<TcpStream>,
474) -> io::Result<()> {
475 let connect_req =
476 parse_connect_request(buffer_connect_request(initial_buf, &mut from_smoltcp).await?)?;
477
478 let connect_headers = match sanitize_connect_headers(
479 connect_req.header_bytes(),
480 &tls_state.secrets.load(),
481 ) {
482 Ok(headers) => headers,
483 Err(action) => {
484 tracing::warn!(dst = %proxy_dst, violation = ?action, "secret violation in CONNECT headers");
485 if matches!(action, ViolationAction::BlockAndTerminate) {
486 shared.trigger_termination();
487 }
488 return Ok(());
489 }
490 };
491
492 let mut proxy_stream = match preconnected_proxy {
494 Some(stream) => stream,
495 None => match TcpStream::connect(proxy_dst).await {
496 Ok(s) => s,
497 Err(e) => {
498 proxy_connect.mark_upstream_connect_failed();
499 shared.proxy_wake.wake();
500 return Err(e);
501 }
502 },
503 };
504
505 if !connect_req.target.is_intercepted(&tls_state) {
506 proxy_stream.write_all(&connect_headers).await?;
507 proxy_stream.flush().await?;
508 let (proxy_resp, header_end) = read_connect_response_headers(&mut proxy_stream).await?;
509 if to_smoltcp
510 .send(Bytes::copy_from_slice(&proxy_resp[..header_end]))
511 .await
512 .is_err()
513 {
514 return Ok(());
515 }
516 if !proxy_resp[header_end..].is_empty()
517 && to_smoltcp
518 .send(Bytes::copy_from_slice(&proxy_resp[header_end..]))
519 .await
520 .is_err()
521 {
522 return Ok(());
523 }
524 shared.proxy_wake.wake();
525 if !connect_response_is_success(&proxy_resp[..header_end]) {
526 proxy_connect.mark_connected();
527 return Ok(());
528 }
529 if !connect_req.post_header_bytes().is_empty() {
530 proxy_stream
531 .write_all(connect_req.post_header_bytes())
532 .await?;
533 }
534 proxy_stream.flush().await?;
535 proxy_connect.mark_connected();
536 return relay_connected_stream(proxy_stream, from_smoltcp, to_smoltcp, shared).await;
537 }
538
539 proxy_stream.write_all(&connect_headers).await?;
540 proxy_stream.flush().await?;
541
542 let (proxy_resp, header_end) = read_connect_response_headers(&mut proxy_stream).await?;
543 if !connect_response_is_success(&proxy_resp[..header_end]) {
544 return Err(io::Error::new(
545 io::ErrorKind::ConnectionRefused,
546 "proxy rejected CONNECT",
547 ));
548 }
549 if !proxy_resp[header_end..].is_empty() {
550 return Err(io::Error::new(
551 io::ErrorKind::InvalidData,
552 "proxy sent unexpected bytes after CONNECT response headers",
553 ));
554 }
555 proxy_connect.mark_connected();
556
557 if to_smoltcp
558 .send(Bytes::copy_from_slice(&proxy_resp[..header_end]))
559 .await
560 .is_err()
561 {
562 return Ok(());
563 }
564 shared.proxy_wake.wake();
565
566 let tls_seed = connect_req.post_header_bytes().to_vec();
567 let tls_guest_dst = connect_req.target.guest_dst(guest_dst, &shared);
568 let expected_sni = connect_req.target.expected_sni.clone();
569
570 tls_proxy_task(
571 TlsProxyContext {
572 guest_dst: tls_guest_dst,
573 connect_dst: proxy_dst,
574 shared,
575 tls_state,
576 network_policy,
577 proxy_connect,
578 upstream_stream: Some(proxy_stream),
579 via_connect: expected_sni.is_some(),
580 expected_sni,
581 },
582 from_smoltcp,
583 to_smoltcp,
584 tls_seed,
585 )
586 .await
587}
588
589async fn relay_connected_stream(
591 stream: TcpStream,
592 mut from_smoltcp: mpsc::Receiver<Bytes>,
593 to_smoltcp: mpsc::Sender<Bytes>,
594 shared: Arc<SharedState>,
595) -> io::Result<()> {
596 let (mut server_rx, mut server_tx) = stream.into_split();
597 let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
598
599 let mut guest_eof = false;
600 loop {
601 tokio::select! {
602 data = from_smoltcp.recv(), if !guest_eof => {
603 match data {
604 Some(bytes) => {
605 server_tx.write_all(&bytes).await?;
606 server_tx.flush().await?;
607 }
608 None => {
611 guest_eof = true;
612 if server_tx.shutdown().await.is_err() {
613 break;
614 }
615 }
616 }
617 }
618 result = server_rx.read(&mut server_buf) => {
619 match result {
620 Ok(0) => break,
621 Ok(n) => {
622 if to_smoltcp
623 .send(Bytes::copy_from_slice(&server_buf[..n]))
624 .await
625 .is_err()
626 {
627 break;
628 }
629 shared.proxy_wake.wake();
630 }
631 Err(e) => return Err(e),
632 }
633 }
634 }
635 }
636
637 Ok(())
638}
639
640async fn buffer_connect_request(
641 mut buf: Vec<u8>,
642 from_smoltcp: &mut mpsc::Receiver<Bytes>,
643) -> io::Result<Vec<u8>> {
644 let timeout_fut = tokio::time::sleep(PEEK_BUDGET);
645 tokio::pin!(timeout_fut);
646
647 loop {
648 if !could_be_connect_request(&buf) {
649 return Err(io::Error::new(
650 io::ErrorKind::InvalidData,
651 "malformed CONNECT request prefix",
652 ));
653 }
654 if headers_end(&buf).is_some() {
655 return Ok(buf);
656 }
657 if buf.len() >= PEEK_BUF_SIZE {
658 return Err(io::Error::new(
659 io::ErrorKind::InvalidData,
660 "CONNECT request headers too large",
661 ));
662 }
663
664 tokio::select! {
665 biased;
666 _ = &mut timeout_fut => {
667 return Err(io::Error::new(
668 io::ErrorKind::TimedOut,
669 "timed out waiting for complete CONNECT request headers",
670 ));
671 }
672 data = from_smoltcp.recv() => match data {
673 Some(bytes) => {
674 buf.extend_from_slice(&bytes);
675 }
676 None => {
677 return Err(io::Error::new(
678 io::ErrorKind::UnexpectedEof,
679 "channel closed before complete CONNECT request headers",
680 ));
681 }
682 }
683 }
684 }
685}
686
687async fn read_connect_response_headers(stream: &mut TcpStream) -> io::Result<(Vec<u8>, usize)> {
688 tokio::time::timeout(PEEK_BUDGET, async {
689 let mut proxy_resp = Vec::with_capacity(256);
690 let mut buf = [0u8; 4096];
691 loop {
692 let n = stream.read(&mut buf).await?;
693 if n == 0 {
694 return Err(io::Error::new(
695 io::ErrorKind::UnexpectedEof,
696 "proxy closed before sending CONNECT response",
697 ));
698 }
699 proxy_resp.extend_from_slice(&buf[..n]);
700 if let Some(end) = headers_end(&proxy_resp) {
701 return Ok((proxy_resp, end));
702 }
703 if proxy_resp.len() > CONNECT_RESP_LIMIT {
704 return Err(io::Error::new(
705 io::ErrorKind::InvalidData,
706 "proxy CONNECT response too large",
707 ));
708 }
709 }
710 })
711 .await
712 .map_err(|_| {
713 io::Error::new(
714 io::ErrorKind::TimedOut,
715 "timed out waiting for proxy CONNECT response",
716 )
717 })?
718}
719
720fn sanitize_connect_headers<'a>(
721 header_bytes: &'a [u8],
722 secrets: &SecretsConfig,
723) -> Result<Cow<'a, [u8]>, ViolationAction> {
724 if secrets.secrets.is_empty() {
725 return Ok(Cow::Borrowed(header_bytes));
726 }
727
728 let mut handler = SecretsHandler::new_plain_http_untrusted_metadata(secrets);
729 handler.substitute(header_bytes)
730}
731
732fn headers_end(buf: &[u8]) -> Option<usize> {
734 buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
735}
736
737fn could_be_connect_request(buf: &[u8]) -> bool {
738 const PREFIX: &[u8] = b"CONNECT ";
739 if buf.is_empty() {
740 return false;
741 }
742 let n = buf.len().min(PREFIX.len());
743 buf[..n].eq_ignore_ascii_case(&PREFIX[..n])
744}
745
746fn parse_connect_request(bytes: Vec<u8>) -> io::Result<ConnectRequest> {
747 let header_end = headers_end(&bytes).ok_or_else(|| {
748 io::Error::new(
749 io::ErrorKind::InvalidData,
750 "incomplete CONNECT request headers",
751 )
752 })?;
753 let target = {
754 let request_line = bytes[..header_end]
755 .split(|&b| b == b'\n')
756 .next()
757 .unwrap_or(&[]);
758 let request_line = std::str::from_utf8(request_line)
759 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "CONNECT line is not UTF-8"))?
760 .trim_end_matches('\r');
761 let mut parts = request_line.split_ascii_whitespace();
762 let method = parts.next().unwrap_or_default();
763 let authority = parts.next().unwrap_or_default();
764 let version = parts.next().unwrap_or_default();
765 if !method.eq_ignore_ascii_case("CONNECT")
766 || authority.is_empty()
767 || !is_http_version(version)
768 || parts.next().is_some()
769 {
770 return Err(io::Error::new(
771 io::ErrorKind::InvalidData,
772 "malformed CONNECT request line",
773 ));
774 }
775 parse_connect_target(authority)?
776 };
777
778 Ok(ConnectRequest {
779 bytes,
780 header_end,
781 target,
782 })
783}
784
785fn parse_connect_target(authority: &str) -> io::Result<ConnectTarget> {
786 let authority = authority.trim();
787 let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
788 let (host, rest) = rest.split_once(']').ok_or_else(|| {
789 io::Error::new(
790 io::ErrorKind::InvalidData,
791 "malformed CONNECT IPv6 authority",
792 )
793 })?;
794 let port = rest.strip_prefix(':').ok_or_else(|| {
795 io::Error::new(io::ErrorKind::InvalidData, "CONNECT authority missing port")
796 })?;
797 (host, port)
798 } else {
799 let (host, port) = authority.rsplit_once(':').ok_or_else(|| {
800 io::Error::new(io::ErrorKind::InvalidData, "CONNECT authority missing port")
801 })?;
802 if host.contains(':') {
803 return Err(io::Error::new(
804 io::ErrorKind::InvalidData,
805 "CONNECT IPv6 authority must be bracketed",
806 ));
807 }
808 (host, port)
809 };
810 let host = host.trim().trim_end_matches('.');
811 if host.is_empty() {
812 return Err(io::Error::new(
813 io::ErrorKind::InvalidData,
814 "CONNECT authority missing host",
815 ));
816 }
817 let port = port
818 .parse::<u16>()
819 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid CONNECT port"))?;
820 let expected_sni = host
821 .parse::<IpAddr>()
822 .is_err()
823 .then(|| host.to_ascii_lowercase());
824
825 Ok(ConnectTarget {
826 host: host.to_ascii_lowercase(),
827 port,
828 expected_sni,
829 })
830}
831
832fn is_http_version(version: &str) -> bool {
833 let Some(version) = version.strip_prefix("HTTP/") else {
834 return false;
835 };
836 let Some((major, minor)) = version.split_once('.') else {
837 return false;
838 };
839 !major.is_empty()
840 && !minor.is_empty()
841 && major.bytes().all(|b| b.is_ascii_digit())
842 && minor.bytes().all(|b| b.is_ascii_digit())
843}
844
845fn connect_response_is_success(headers: &[u8]) -> bool {
846 let Some(status_line) = headers.split(|&b| b == b'\n').next() else {
847 return false;
848 };
849 let Ok(status_line) = std::str::from_utf8(status_line) else {
850 return false;
851 };
852 let mut parts = status_line.trim_end_matches('\r').split_ascii_whitespace();
853 let version = parts.next().unwrap_or_default();
854 let status = parts.next().unwrap_or_default();
855 is_http_version(version)
856 && status.len() == 3
857 && status
858 .parse::<u16>()
859 .is_ok_and(|code| (200..300).contains(&code))
860}
861
862fn extract_http_host(buf: &[u8]) -> Option<String> {
872 if buf.first() == Some(&0x16) {
873 return None;
874 }
875 let mut headers = vec![httparse::EMPTY_HEADER; (buf.len() / 4).max(16)];
881 let mut req = httparse::Request::new(&mut headers);
882 req.parse(buf).ok()?;
883 req.headers
884 .iter()
885 .find(|h| h.name.eq_ignore_ascii_case("host"))
886 .and_then(|h| std::str::from_utf8(h.value).ok())
887 .map(|v| {
888 let host = v.trim();
889 host.rsplit_once(':')
891 .map(|(h, _)| h)
892 .unwrap_or(host)
893 .to_ascii_lowercase()
894 })
895 .filter(|h| !h.is_empty())
896}
897
898#[allow(clippy::too_many_arguments)]
915async fn classify_first_flight(
916 mut buf: Vec<u8>,
917 from_smoltcp: &mut mpsc::Receiver<Bytes>,
918 server_rx: &mut tokio::net::tcp::OwnedReadHalf,
919 to_smoltcp: &mpsc::Sender<Bytes>,
920 shared: &SharedState,
921 want_headers: bool,
922 max: usize,
923 budget: Duration,
924) -> io::Result<(Vec<u8>, bool)> {
925 let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
926 let timeout_fut = tokio::time::sleep(budget);
927 tokio::pin!(timeout_fut);
928
929 loop {
930 if !buf.is_empty() {
936 let is_tls = buf.first() == Some(&0x16);
937 let not_http = !is_tls
938 && (!looks_like_http_request_prefix(&buf) || first_line_is_not_http_request(&buf));
939 let done = !want_headers
940 || is_tls
941 || not_http
942 || buf.len() >= max
943 || buf.windows(4).any(|w| w == b"\r\n\r\n");
944 if done {
945 return Ok((buf, is_tls));
946 }
947 }
948
949 tokio::select! {
950 biased;
951 _ = &mut timeout_fut => {
952 let is_tls = buf.first() == Some(&0x16);
953 return Ok((buf, is_tls));
954 }
955 guest = from_smoltcp.recv() => match guest {
958 Some(bytes) => buf.extend_from_slice(&bytes),
959 None => {
960 let is_tls = buf.first() == Some(&0x16);
961 return Ok((buf, is_tls));
962 }
963 },
964 server = server_rx.read(&mut server_buf) => match server {
967 Ok(0) => {
968 let is_tls = buf.first() == Some(&0x16);
969 return Ok((buf, is_tls));
970 }
971 Ok(n) => {
972 let data = Bytes::copy_from_slice(&server_buf[..n]);
973 if to_smoltcp.send(data).await.is_err() {
974 let is_tls = buf.first() == Some(&0x16);
975 return Ok((buf, is_tls));
976 }
977 shared.proxy_wake.wake();
978 }
979 Err(e) => return Err(e),
980 },
981 }
982 }
983}
984
985async fn peek_for_sni(
995 rx: &mut mpsc::Receiver<Bytes>,
996 max: usize,
997 budget: Duration,
998) -> (Vec<u8>, Option<String>) {
999 let mut buf = Vec::with_capacity(PEEK_BUF_SIZE.min(8192));
1000 let timeout_fut = tokio::time::sleep(budget);
1001 tokio::pin!(timeout_fut);
1002
1003 let raw_sni = loop {
1004 tokio::select! {
1005 biased;
1006 _ = &mut timeout_fut => break None,
1007 data = rx.recv() => {
1008 match data {
1009 Some(bytes) => {
1010 buf.extend_from_slice(&bytes);
1011 if buf.first() != Some(&0x16) {
1016 break None;
1017 }
1018 if let Some(name) = sni::extract_sni(&buf) {
1019 break Some(name);
1020 }
1021 if buf.len() >= max {
1022 break None;
1023 }
1024 }
1025 None => break None,
1026 }
1027 }
1028 }
1029 };
1030
1031 let canonical = raw_sni.map(|s| s.trim_end_matches('.').to_ascii_lowercase());
1032 (buf, canonical)
1033}
1034
1035#[cfg(test)]
1040mod tests {
1041 use super::*;
1042
1043 fn synthetic_client_hello(sni: &str) -> Vec<u8> {
1047 let host_bytes = sni.as_bytes();
1050 let host_len = host_bytes.len() as u16;
1051 let server_name_list_len = 3 + host_len; let extension_data_len = 2 + server_name_list_len; let extensions_total = 4 + extension_data_len; let mut body = Vec::new();
1056 body.extend_from_slice(&[0x03, 0x03]);
1058 body.extend_from_slice(&[0u8; 32]);
1060 body.push(0);
1062 body.extend_from_slice(&[0x00, 0x02, 0x00, 0x2f]);
1064 body.extend_from_slice(&[0x01, 0x00]);
1066 body.extend_from_slice(&extensions_total.to_be_bytes());
1068 body.extend_from_slice(&[0x00, 0x00]);
1070 body.extend_from_slice(&extension_data_len.to_be_bytes());
1071 body.extend_from_slice(&server_name_list_len.to_be_bytes());
1072 body.push(0x00); body.extend_from_slice(&host_len.to_be_bytes());
1074 body.extend_from_slice(host_bytes);
1075
1076 let handshake_len = body.len() as u32;
1077 let mut hs = Vec::new();
1078 hs.push(0x01); hs.extend_from_slice(&handshake_len.to_be_bytes()[1..]); hs.extend_from_slice(&body);
1081
1082 let record_len = hs.len() as u16;
1083 let mut record = Vec::new();
1084 record.extend_from_slice(&[0x16, 0x03, 0x01]); record.extend_from_slice(&record_len.to_be_bytes());
1086 record.extend_from_slice(&hs);
1087
1088 record
1089 }
1090
1091 #[test]
1092 fn could_be_connect_request_matches_split_prefixes_only() {
1093 assert!(could_be_connect_request(b"C"));
1094 assert!(could_be_connect_request(b"connect "));
1095 assert!(could_be_connect_request(b"CONNECT example.com:443"));
1096 assert!(!could_be_connect_request(b"CLIENT"));
1097 assert!(!could_be_connect_request(b"GET / HTTP/1.1\r\n"));
1098 }
1099
1100 #[tokio::test]
1101 async fn buffer_connect_request_reads_split_headers() {
1102 let (tx, mut rx) = mpsc::channel(4);
1103 tx.send(Bytes::from_static(b"NECT example.com:443 HTTP/1.1\r\n"))
1104 .await
1105 .unwrap();
1106 tx.send(Bytes::from_static(b"Host: example.com\r\n\r\n"))
1107 .await
1108 .unwrap();
1109 drop(tx);
1110
1111 let buffered = buffer_connect_request(b"CON".to_vec(), &mut rx)
1112 .await
1113 .unwrap();
1114 let parsed = parse_connect_request(buffered).unwrap();
1115
1116 assert_eq!(parsed.target.host, "example.com");
1117 assert_eq!(parsed.target.port, 443);
1118 assert_eq!(parsed.target.expected_sni.as_deref(), Some("example.com"));
1119 assert!(parsed.post_header_bytes().is_empty());
1120 }
1121
1122 #[test]
1123 fn parse_connect_request_preserves_post_header_tls_seed() {
1124 let mut request = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n".to_vec();
1125 request.extend_from_slice(b"\x16\x03\x01client-hello");
1126
1127 let parsed = parse_connect_request(request).unwrap();
1128
1129 assert_eq!(
1130 parsed.header_bytes(),
1131 b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n"
1132 );
1133 assert_eq!(parsed.post_header_bytes(), b"\x16\x03\x01client-hello");
1134 }
1135
1136 #[test]
1137 fn parse_connect_target_requires_authority_port() {
1138 assert!(parse_connect_target("example.com").is_err());
1139 assert!(parse_connect_target("2001:db8::1:443").is_err());
1140
1141 let target = parse_connect_target("[2001:db8::1]:8443").unwrap();
1142 assert_eq!(target.host, "2001:db8::1");
1143 assert_eq!(target.port, 8443);
1144 assert_eq!(target.expected_sni, None);
1145 }
1146
1147 #[test]
1148 fn connect_response_success_requires_exact_2xx_status_code() {
1149 assert!(connect_response_is_success(
1150 b"HTTP/1.1 200 Connection Established\r\n\r\n"
1151 ));
1152 assert!(connect_response_is_success(
1153 b"HTTP/1.1 204 Connection Established\r\n\r\n"
1154 ));
1155 assert!(!connect_response_is_success(b"HTTP/1.1 2000 Weird\r\n\r\n"));
1156 assert!(!connect_response_is_success(b"HTTP/1.1 199 Nope\r\n\r\n"));
1157 assert!(!connect_response_is_success(b"NOTHTTP 200 OK\r\n\r\n"));
1158 }
1159
1160 #[tokio::test]
1161 async fn peek_for_sni_extracts_and_canonicalizes() {
1162 let (tx, mut rx) = mpsc::channel(4);
1163 let hello = synthetic_client_hello("Example.COM");
1164 tx.send(Bytes::from(hello.clone())).await.unwrap();
1165 drop(tx); let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1168 assert_eq!(sni.as_deref(), Some("example.com"));
1169 assert_eq!(buf, hello);
1170 }
1171
1172 #[tokio::test]
1173 async fn peek_for_sni_returns_none_on_channel_close_without_data() {
1174 let (tx, mut rx) = mpsc::channel::<Bytes>(1);
1175 drop(tx);
1176 let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1177 assert!(buf.is_empty());
1178 assert_eq!(sni, None);
1179 }
1180
1181 #[tokio::test]
1182 async fn peek_for_sni_returns_none_on_non_tls_data() {
1183 let (tx, mut rx) = mpsc::channel(4);
1184 tx.send(Bytes::from_static(
1186 b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
1187 ))
1188 .await
1189 .unwrap();
1190 drop(tx);
1191 let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1192 assert!(
1193 !buf.is_empty(),
1194 "buffered bytes must be returned for replay"
1195 );
1196 assert_eq!(sni, None);
1197 }
1198
1199 #[tokio::test]
1200 async fn peek_for_sni_falls_back_on_timeout() {
1201 let (tx, mut rx) = mpsc::channel::<Bytes>(1);
1202 let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, Duration::from_millis(50)).await;
1204 drop(tx);
1205 assert!(buf.is_empty());
1206 assert_eq!(sni, None);
1207 }
1208
1209 #[tokio::test]
1210 async fn peek_for_sni_caps_at_max_bytes() {
1211 let (tx, mut rx) = mpsc::channel(4);
1212 let mut first = vec![0u8; 8192];
1216 first[0] = 0x16;
1217 tx.send(Bytes::from(first)).await.unwrap();
1218 tx.send(Bytes::from(vec![0u8; 8192])).await.unwrap();
1219 tx.send(Bytes::from(vec![0u8; 8192])).await.unwrap();
1220 drop(tx);
1221
1222 let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1223 assert_eq!(sni, None, "no SNI in non-TLS data");
1224 assert!(
1225 buf.len() >= PEEK_BUF_SIZE,
1226 "buffer must hit the cap before bail-out: got {}",
1227 buf.len()
1228 );
1229 }
1230
1231 #[tokio::test]
1232 async fn peek_for_sni_bails_immediately_on_non_tls_first_byte() {
1233 let (tx, mut rx) = mpsc::channel(4);
1234 tx.send(Bytes::from_static(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
1236 .await
1237 .unwrap();
1238 drop(tx);
1239
1240 let started = std::time::Instant::now();
1243 let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1244 let elapsed = started.elapsed();
1245 assert_eq!(sni, None);
1246 assert!(buf.starts_with(b"GET"));
1247 assert!(
1248 elapsed < Duration::from_millis(500),
1249 "non-TLS bail must be fast: took {elapsed:?}"
1250 );
1251 }
1252
1253 use std::net::IpAddr;
1258 use std::time::Duration as StdDuration;
1259
1260 use crate::policy::{Action, Destination, NetworkPolicy, PortRange, Rule};
1261 use crate::shared::{ResolvedHostnameFamily, SharedState};
1262
1263 const SHARED_FASTLY_IP: &str = "151.101.0.223";
1264
1265 fn shared_with(host: &str, ip: &str) -> SharedState {
1266 let shared = SharedState::new(4);
1267 shared.cache_resolved_hostname(
1268 host,
1269 ResolvedHostnameFamily::Ipv4,
1270 [ip.parse::<IpAddr>().unwrap()],
1271 StdDuration::from_secs(60),
1272 );
1273 shared
1274 }
1275
1276 fn allow_https(domain: &str) -> Rule {
1277 Rule {
1278 direction: crate::policy::Direction::Egress,
1279 destination: Destination::Domain(domain.parse().unwrap()),
1280 protocols: vec![Protocol::Tcp],
1281 ports: vec![PortRange::single(443)],
1282 action: Action::Allow,
1283 }
1284 }
1285
1286 #[tokio::test]
1289 async fn integration_sni_overrides_cache_for_over_allow() {
1290 let shared = shared_with("pypi.org", SHARED_FASTLY_IP);
1291 let policy = NetworkPolicy {
1292 default_egress: Action::Deny,
1293 default_ingress: Action::Allow,
1294 rules: vec![allow_https("pypi.org")],
1295 };
1296 let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1297
1298 let (tx, mut rx) = mpsc::channel(4);
1299 tx.send(Bytes::from(synthetic_client_hello("evil.com")))
1300 .await
1301 .unwrap();
1302 drop(tx);
1303
1304 let (initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1305 assert_eq!(sni.as_deref(), Some("evil.com"));
1306 assert!(!initial_buf.is_empty());
1307
1308 let source = sni
1309 .as_deref()
1310 .map(HostnameSource::Sni)
1311 .unwrap_or(HostnameSource::CacheOnly);
1312 let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1313 assert_eq!(
1314 eval,
1315 EgressEvaluation::Deny,
1316 "SNI=evil.com must not piggy-back on the cached pypi.org match",
1317 );
1318 }
1319
1320 #[tokio::test]
1323 async fn integration_sni_overrides_cache_for_over_block() {
1324 let shared = shared_with("ads.example.com", SHARED_FASTLY_IP);
1325 let policy = NetworkPolicy {
1326 default_egress: Action::Allow,
1327 default_ingress: Action::Allow,
1328 rules: vec![Rule::deny_egress(Destination::Domain(
1329 "ads.example.com".parse().unwrap(),
1330 ))],
1331 };
1332 let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1333
1334 let (tx, mut rx) = mpsc::channel(4);
1335 tx.send(Bytes::from(synthetic_client_hello("api.example.com")))
1336 .await
1337 .unwrap();
1338 drop(tx);
1339
1340 let (_initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1341 assert_eq!(sni.as_deref(), Some("api.example.com"));
1342
1343 let source = sni
1344 .as_deref()
1345 .map(HostnameSource::Sni)
1346 .unwrap_or(HostnameSource::CacheOnly);
1347 let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1348 assert_eq!(
1349 eval,
1350 EgressEvaluation::Allow,
1351 "SNI=api.example.com must not be caught by the deny on ads.example.com",
1352 );
1353 }
1354
1355 #[tokio::test]
1358 async fn integration_non_tls_falls_back_to_cache() {
1359 let shared = shared_with("pypi.org", SHARED_FASTLY_IP);
1360 let policy = NetworkPolicy {
1361 default_egress: Action::Deny,
1362 default_ingress: Action::Allow,
1363 rules: vec![allow_https("pypi.org")],
1364 };
1365 let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1366
1367 let (tx, mut rx) = mpsc::channel(4);
1368 tx.send(Bytes::from_static(
1370 b"GET / HTTP/1.1\r\nHost: pypi.org\r\n\r\n",
1371 ))
1372 .await
1373 .unwrap();
1374 drop(tx);
1375
1376 let (initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1377 assert_eq!(sni, None, "non-TLS data → no SNI");
1378 assert!(
1379 !initial_buf.is_empty(),
1380 "buffered bytes must survive for replay"
1381 );
1382
1383 let source = sni
1384 .as_deref()
1385 .map(HostnameSource::Sni)
1386 .unwrap_or(HostnameSource::CacheOnly);
1387 let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1388 assert_eq!(
1389 eval,
1390 EgressEvaluation::Allow,
1391 "cache-only fallback must still allow the cached hostname's IP",
1392 );
1393 }
1394
1395 #[tokio::test]
1398 async fn integration_sni_matches_domain_suffix_with_cache_binding() {
1399 let shared = shared_with("files.pythonhosted.org", SHARED_FASTLY_IP);
1400 let policy = NetworkPolicy {
1401 default_egress: Action::Deny,
1402 default_ingress: Action::Allow,
1403 rules: vec![Rule {
1404 direction: crate::policy::Direction::Egress,
1405 destination: Destination::DomainSuffix(".pythonhosted.org".parse().unwrap()),
1406 protocols: vec![Protocol::Tcp],
1407 ports: vec![PortRange::single(443)],
1408 action: Action::Allow,
1409 }],
1410 };
1411 let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1412
1413 let (tx, mut rx) = mpsc::channel(4);
1414 tx.send(Bytes::from(synthetic_client_hello(
1415 "files.pythonhosted.org",
1416 )))
1417 .await
1418 .unwrap();
1419 drop(tx);
1420
1421 let (_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1422 let source = sni
1423 .as_deref()
1424 .map(HostnameSource::Sni)
1425 .unwrap_or(HostnameSource::CacheOnly);
1426 let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1427 assert_eq!(eval, EgressEvaluation::Allow);
1428 }
1429
1430 #[tokio::test]
1435 async fn integration_sni_denies_domain_suffix_without_cache_binding() {
1436 let shared = SharedState::new(4); let policy = NetworkPolicy {
1438 default_egress: Action::Deny,
1439 default_ingress: Action::Allow,
1440 rules: vec![Rule {
1441 direction: crate::policy::Direction::Egress,
1442 destination: Destination::DomainSuffix(".pythonhosted.org".parse().unwrap()),
1443 protocols: vec![Protocol::Tcp],
1444 ports: vec![PortRange::single(443)],
1445 action: Action::Allow,
1446 }],
1447 };
1448 let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1449
1450 let (tx, mut rx) = mpsc::channel(4);
1451 tx.send(Bytes::from(synthetic_client_hello(
1452 "files.pythonhosted.org",
1453 )))
1454 .await
1455 .unwrap();
1456 drop(tx);
1457
1458 let (_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1459 let source = sni
1460 .as_deref()
1461 .map(HostnameSource::Sni)
1462 .unwrap_or(HostnameSource::CacheOnly);
1463 let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1464 assert_eq!(eval, EgressEvaluation::Deny);
1465 }
1466
1467 #[test]
1470 fn extract_http_host_basic() {
1471 let buf = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
1472 assert_eq!(extract_http_host(buf), Some("example.com".into()));
1473 }
1474
1475 #[test]
1476 fn extract_http_host_strips_port() {
1477 let buf = b"POST /api HTTP/1.1\r\nHost: api.company.com:8080\r\n\r\n";
1478 assert_eq!(extract_http_host(buf), Some("api.company.com".into()));
1479 }
1480
1481 #[test]
1482 fn extract_http_host_case_insensitive_lowercased() {
1483 let buf = b"GET / HTTP/1.1\r\nhost: Example.COM\r\n\r\n";
1484 assert_eq!(extract_http_host(buf), Some("example.com".into()));
1485 }
1486
1487 #[test]
1488 fn extract_http_host_no_host_header() {
1489 let buf = b"GET / HTTP/1.1\r\nX-Other: foo\r\n\r\n";
1490 assert_eq!(extract_http_host(buf), None);
1491 }
1492
1493 #[test]
1494 fn extract_http_host_incomplete_headers() {
1495 let buf = b"GET / HTTP/1.1\r\nHost: x";
1496 assert_eq!(extract_http_host(buf), None);
1497 }
1498
1499 #[test]
1500 fn extract_http_host_tls_first_byte() {
1501 let buf = [0x16u8, 0x03, 0x01, 0x00, 0x01];
1502 assert_eq!(extract_http_host(&buf), None);
1503 }
1504
1505 #[test]
1506 fn extract_http_host_with_many_headers() {
1507 let mut req = Vec::from(&b"GET / HTTP/1.1\r\n"[..]);
1510 for i in 0..100 {
1511 req.extend_from_slice(format!("X-Pad-{i}: v\r\n").as_bytes());
1512 }
1513 req.extend_from_slice(b"Host: example.com\r\n\r\n");
1514 assert_eq!(extract_http_host(&req), Some("example.com".into()));
1515 }
1516
1517 use std::sync::Arc;
1520 use tokio::io::AsyncReadExt;
1521 use tokio::net::TcpListener;
1522 use tokio::task::JoinHandle;
1523
1524 use crate::secrets::config::{HostPattern, SecretEntry, SecretInjection, SecretsConfig};
1525
1526 fn make_plain_http_secret(placeholder: &str, value: &str, require_tls: bool) -> SecretsConfig {
1527 SecretsConfig {
1528 secrets: vec![SecretEntry {
1529 env_var: "API_KEY".into(),
1530 value: zeroize::Zeroizing::new(value.into()),
1531 source: None,
1532 placeholder: placeholder.into(),
1533 allowed_hosts: vec![HostPattern::Any],
1534 injection: SecretInjection {
1535 headers: true,
1536 basic_auth: false,
1537 query_params: false,
1538 body: false,
1539 },
1540 on_violation: None,
1541 require_tls_identity: require_tls,
1542 }],
1543 ..Default::default()
1544 }
1545 }
1546
1547 fn make_host_bound_secret(placeholder: &str, value: &str, host: &str) -> SecretsConfig {
1548 SecretsConfig {
1549 secrets: vec![SecretEntry {
1550 env_var: "API_KEY".into(),
1551 value: zeroize::Zeroizing::new(value.into()),
1552 source: None,
1553 placeholder: placeholder.into(),
1554 allowed_hosts: vec![HostPattern::Exact(host.into())],
1555 injection: SecretInjection::default(),
1556 on_violation: None,
1557 require_tls_identity: true,
1558 }],
1559 ..Default::default()
1560 }
1561 }
1562
1563 #[test]
1564 fn sanitize_connect_headers_blocks_placeholder_metadata_header_by_default() {
1565 let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1566 let headers = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nProxy-Authorization: Bearer $MSB_KEY\r\nUser-Agent: curl\r\n\r\n";
1567
1568 assert_eq!(
1569 sanitize_connect_headers(headers, &secrets),
1570 Err(ViolationAction::BlockAndLog)
1571 );
1572 }
1573
1574 #[test]
1575 fn sanitize_connect_headers_respects_block_and_terminate() {
1576 let mut secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1577 secrets.on_violation = ViolationAction::BlockAndTerminate;
1578 let headers = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nProxy-Authorization: Bearer $MSB_KEY\r\n\r\n";
1579
1580 assert_eq!(
1581 sanitize_connect_headers(headers, &secrets),
1582 Err(ViolationAction::BlockAndTerminate)
1583 );
1584 }
1585
1586 #[test]
1587 fn sanitize_connect_headers_respects_explicit_passthrough() {
1588 let mut secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1589 secrets.on_violation = ViolationAction::Passthrough(vec![HostPattern::Any]);
1590 let headers = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nProxy-Authorization: Bearer $MSB_KEY\r\n\r\n";
1591
1592 let sanitized = sanitize_connect_headers(headers, &secrets).unwrap();
1593
1594 assert_eq!(sanitized.as_ref(), headers);
1595 assert!(
1596 !String::from_utf8_lossy(sanitized.as_ref()).contains("real-secret-value"),
1597 "passthrough must never substitute real secrets into CONNECT metadata"
1598 );
1599 }
1600
1601 #[test]
1602 fn sanitize_connect_headers_keeps_safe_metadata_headers() {
1603 let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1604 let headers =
1605 b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nUser-Agent: curl\r\n\r\n";
1606
1607 let sanitized = sanitize_connect_headers(headers, &secrets).unwrap();
1608
1609 assert_eq!(sanitized.as_ref(), headers);
1610 }
1611
1612 #[test]
1613 fn sanitize_connect_headers_blocks_placeholder_in_request_line() {
1614 let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1615 let headers = b"CONNECT $MSB_KEY:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n";
1616
1617 assert_eq!(
1618 sanitize_connect_headers(headers, &secrets),
1619 Err(ViolationAction::BlockAndLog)
1620 );
1621 }
1622
1623 async fn spawn_sink() -> (SocketAddr, JoinHandle<Vec<u8>>) {
1624 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1625 let addr = listener.local_addr().unwrap();
1626 let handle = tokio::spawn(async move {
1627 let (mut stream, _) = listener.accept().await.unwrap();
1628 let mut received = Vec::new();
1629 let mut buf = vec![0u8; 4096];
1630 loop {
1631 match stream.read(&mut buf).await {
1632 Ok(0) | Err(_) => break,
1633 Ok(n) => received.extend_from_slice(&buf[..n]),
1634 }
1635 }
1636 received
1637 });
1638 (addr, handle)
1639 }
1640
1641 async fn relay_through_proxy(
1642 request: Vec<u8>,
1643 secrets: SecretsConfig,
1644 handle: JoinHandle<Vec<u8>>,
1645 server_addr: SocketAddr,
1646 ) -> Vec<u8> {
1647 let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1648 let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1649 let shared = SharedState::new(4);
1650 let policy = Arc::new(NetworkPolicy::default());
1651 let secrets = Arc::new(secrets);
1652 let proxy_connect = Arc::new(ProxyConnectState::new());
1653
1654 from_tx.send(Bytes::from(request)).await.unwrap();
1655 drop(from_tx);
1656
1657 tcp_proxy_task(
1658 server_addr,
1659 server_addr,
1660 from_rx,
1661 to_tx,
1662 Arc::new(shared),
1663 policy,
1664 secrets,
1665 None,
1666 proxy_connect,
1667 )
1668 .await
1669 .unwrap();
1670
1671 handle.await.unwrap()
1672 }
1673
1674 #[tokio::test]
1675 async fn plain_http_substitutes_placeholder_when_host_arrives_in_second_segment() {
1676 let (addr, sink) = spawn_sink().await;
1679 let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", false);
1680
1681 let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1682 let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1683 let proxy_connect = Arc::new(ProxyConnectState::new());
1684
1685 from_tx
1686 .send(Bytes::from_static(b"GET /api HTTP/1.1\r\n"))
1687 .await
1688 .unwrap();
1689 from_tx
1690 .send(Bytes::from_static(
1691 b"Host: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n",
1692 ))
1693 .await
1694 .unwrap();
1695 drop(from_tx);
1696
1697 tcp_proxy_task(
1698 addr,
1699 addr,
1700 from_rx,
1701 to_tx,
1702 Arc::new(SharedState::new(4)),
1703 Arc::new(NetworkPolicy::default()),
1704 Arc::new(secrets),
1705 None,
1706 proxy_connect,
1707 )
1708 .await
1709 .unwrap();
1710
1711 let wire = String::from_utf8(sink.await.unwrap()).unwrap();
1712 assert!(wire.contains("real-secret-value"), "got: {wire:?}");
1713 assert!(!wire.contains("$MSB_KEY"), "got: {wire:?}");
1714 }
1715
1716 #[tokio::test]
1717 async fn plain_http_forwards_placeholder_to_allowed_host_with_split_headers() {
1718 let (addr, sink) = spawn_sink().await;
1723
1724 let shared = SharedState::new(4);
1725 shared.cache_resolved_hostname(
1726 "example.com",
1727 ResolvedHostnameFamily::Ipv4,
1728 ["127.0.0.1".parse::<IpAddr>().unwrap()],
1729 StdDuration::from_secs(60),
1730 );
1731
1732 let secrets = SecretsConfig {
1733 secrets: vec![SecretEntry {
1734 env_var: "API_KEY".into(),
1735 value: zeroize::Zeroizing::new("real-secret-value".into()),
1736 source: None,
1737 placeholder: "$MSB_KEY".into(),
1738 allowed_hosts: vec![HostPattern::Exact("example.com".into())],
1739 injection: SecretInjection {
1740 headers: true,
1741 basic_auth: false,
1742 query_params: false,
1743 body: false,
1744 },
1745 on_violation: None,
1746 require_tls_identity: true,
1747 }],
1748 ..Default::default()
1749 };
1750
1751 let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1752 let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1753 let proxy_connect = Arc::new(ProxyConnectState::new());
1754
1755 from_tx
1756 .send(Bytes::from_static(b"GET /api HTTP/1.1\r\n"))
1757 .await
1758 .unwrap();
1759 from_tx
1760 .send(Bytes::from_static(
1761 b"Host: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n",
1762 ))
1763 .await
1764 .unwrap();
1765 drop(from_tx);
1766
1767 tcp_proxy_task(
1768 addr,
1769 addr,
1770 from_rx,
1771 to_tx,
1772 Arc::new(shared),
1773 Arc::new(NetworkPolicy::default()),
1774 Arc::new(secrets),
1775 None,
1776 proxy_connect,
1777 )
1778 .await
1779 .unwrap();
1780
1781 let wire = String::from_utf8(sink.await.unwrap()).unwrap();
1782 assert!(
1783 wire.contains("Host: example.com"),
1784 "request must reach the allowed host, got: {wire:?}"
1785 );
1786 assert!(
1787 wire.contains("$MSB_KEY"),
1788 "placeholder must be forwarded unchanged for a require_tls_identity secret, got: {wire:?}"
1789 );
1790 assert!(
1791 !wire.contains("real-secret-value"),
1792 "secret must never be substituted over plain HTTP, got: {wire:?}"
1793 );
1794 }
1795
1796 #[tokio::test]
1797 async fn plain_http_substitutes_placeholder_in_first_flight() {
1798 let (addr, sink) = spawn_sink().await;
1799
1800 let request =
1801 b"GET /api HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n"
1802 .to_vec();
1803 let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", false);
1804
1805 let wire =
1806 String::from_utf8(relay_through_proxy(request, secrets, sink, addr).await).unwrap();
1807 assert!(
1808 wire.contains("real-secret-value"),
1809 "real value must reach server, got: {wire:?}"
1810 );
1811 assert!(
1812 !wire.contains("$MSB_KEY"),
1813 "placeholder must not reach server, got: {wire:?}"
1814 );
1815 }
1816
1817 #[tokio::test]
1818 async fn plain_http_no_substitution_when_require_tls_identity_true() {
1819 let (addr, sink) = spawn_sink().await;
1820
1821 let request =
1822 b"GET /api HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n"
1823 .to_vec();
1824 let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", true);
1825
1826 let wire =
1827 String::from_utf8_lossy(&relay_through_proxy(request, secrets, sink, addr).await)
1828 .into_owned();
1829 assert!(
1830 wire.contains("$MSB_KEY"),
1831 "placeholder must be forwarded unchanged when require_tls_identity=true, got: {wire:?}"
1832 );
1833 assert!(
1834 !wire.contains("real-secret-value"),
1835 "real value must not leak when require_tls_identity=true, got: {wire:?}"
1836 );
1837 }
1838
1839 #[tokio::test]
1840 async fn plain_http_large_body_forwarded_verbatim_in_relay_loop() {
1841 let (addr, sink) = spawn_sink().await;
1845 let secrets = make_plain_http_secret("$MSB_KEY", "real-value", false);
1846
1847 let body = "x".repeat(32_000);
1848 let header = format!(
1849 "POST /upload HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\nContent-Length: {}\r\n\r\n",
1850 body.len()
1851 );
1852
1853 let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1854 let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1855 let proxy_connect = Arc::new(ProxyConnectState::new());
1856
1857 from_tx
1858 .send(Bytes::from(header.into_bytes()))
1859 .await
1860 .unwrap();
1861 from_tx
1862 .send(Bytes::from(body.clone().into_bytes()))
1863 .await
1864 .unwrap();
1865 drop(from_tx);
1866
1867 tcp_proxy_task(
1868 addr,
1869 addr,
1870 from_rx,
1871 to_tx,
1872 Arc::new(SharedState::new(4)),
1873 Arc::new(NetworkPolicy::default()),
1874 Arc::new(secrets),
1875 None,
1876 proxy_connect,
1877 )
1878 .await
1879 .unwrap();
1880
1881 let wire = String::from_utf8_lossy(&sink.await.unwrap()).into_owned();
1882 assert!(wire.contains(&body), "got {} bytes", wire.len());
1883 assert!(!wire.contains("$MSB_KEY"), "got: {wire:?}");
1884 }
1885}