Skip to main content

microsandbox_network/tcp/
proxy.rs

1//! Bidirectional TCP proxy: smoltcp socket ↔ channels ↔ tokio socket.
2//!
3//! Each outbound guest TCP connection gets a proxy task that opens a real
4//! TCP connection to the destination via tokio and relays data between the
5//! channel pair (connected to the smoltcp socket in the poll loop) and the
6//! real server.
7
8use 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 super::connection::ProxyConnectState;
20#[cfg(test)]
21use super::connection::ProxyConnectStatus;
22use super::upstream::UpstreamTcpTarget;
23use crate::netstack::shared::SharedState;
24use crate::policy::{EgressEvaluation, HostnameSource, NetworkPolicy, Protocol};
25use crate::proxy::ResolvedOutboundProxy;
26use crate::secrets::config::{SecretsConfig, SecretsConfigExt, ViolationAction};
27use crate::secrets::handler::{
28    SecretsHandler, first_line_is_not_http_request, looks_like_http_request_prefix,
29};
30use crate::tls::proxy::TlsProxy;
31use crate::tls::sni;
32use crate::tls::state::TlsState;
33
34//--------------------------------------------------------------------------------------------------
35// Constants
36//--------------------------------------------------------------------------------------------------
37
38/// Buffer size for reading from the real server.
39const SERVER_READ_BUF_SIZE: usize = 16384;
40
41/// Max bytes buffered while reading the proxy's CONNECT response headers.
42const CONNECT_RESP_LIMIT: usize = 8192;
43
44/// Max bytes to buffer while peeking for the ClientHello's SNI.
45const PEEK_BUF_SIZE: usize = 16384;
46
47/// Upper bound on time spent buffering the first flight before
48/// falling back to a cache-only egress decision.
49const PEEK_BUDGET: Duration = Duration::from_secs(5);
50
51//--------------------------------------------------------------------------------------------------
52// Types
53//--------------------------------------------------------------------------------------------------
54
55#[derive(Debug)]
56struct ConnectRequest {
57    bytes: Vec<u8>,
58    header_end: usize,
59    target: ConnectTarget,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63struct ConnectTarget {
64    host: String,
65    port: u16,
66    expected_sni: Option<String>,
67}
68
69/// Per-connection TCP proxy task and the state it owns.
70pub(crate) struct TcpProxy {
71    guest_dst: SocketAddr,
72    connect_target: UpstreamTcpTarget,
73    from_smoltcp: mpsc::Receiver<Bytes>,
74    to_smoltcp: mpsc::Sender<Bytes>,
75    shared: Arc<SharedState>,
76    network_policy: Arc<NetworkPolicy>,
77    secrets: Arc<SecretsConfig>,
78    tls_state: Option<Arc<TlsState>>,
79    proxy_connect: Arc<ProxyConnectState>,
80    outbound_proxy: Option<Arc<ResolvedOutboundProxy>>,
81}
82
83//--------------------------------------------------------------------------------------------------
84// Methods
85//--------------------------------------------------------------------------------------------------
86
87impl ConnectRequest {
88    fn header_bytes(&self) -> &[u8] {
89        &self.bytes[..self.header_end]
90    }
91
92    fn post_header_bytes(&self) -> &[u8] {
93        &self.bytes[self.header_end..]
94    }
95}
96
97impl ConnectTarget {
98    fn is_intercepted(&self, tls_state: &TlsState) -> bool {
99        tls_state.config.intercepted_ports.contains(&self.port)
100    }
101
102    fn guest_dst(&self, fallback: SocketAddr, shared: &SharedState) -> SocketAddr {
103        if let Ok(ip) = self.host.parse::<IpAddr>() {
104            return SocketAddr::new(ip, self.port);
105        }
106
107        if self.host.eq_ignore_ascii_case(crate::HOST_ALIAS) {
108            match fallback.ip() {
109                IpAddr::V4(_) => {
110                    if let Some(ip) = shared.gateway_ipv4() {
111                        return SocketAddr::new(IpAddr::V4(ip), self.port);
112                    }
113                }
114                IpAddr::V6(_) => {
115                    if let Some(ip) = shared.gateway_ipv6() {
116                        return SocketAddr::new(IpAddr::V6(ip), self.port);
117                    }
118                }
119            }
120            if let Some(ip) = shared.gateway_ipv4() {
121                return SocketAddr::new(IpAddr::V4(ip), self.port);
122            }
123            if let Some(ip) = shared.gateway_ipv6() {
124                return SocketAddr::new(IpAddr::V6(ip), self.port);
125            }
126        }
127
128        SocketAddr::new(fallback.ip(), self.port)
129    }
130}
131
132impl TcpProxy {
133    /// Build a proxy for a newly established guest TCP connection.
134    #[allow(clippy::too_many_arguments)]
135    pub(crate) fn new(
136        guest_dst: SocketAddr,
137        connect_target: UpstreamTcpTarget,
138        from_smoltcp: mpsc::Receiver<Bytes>,
139        to_smoltcp: mpsc::Sender<Bytes>,
140        shared: Arc<SharedState>,
141        network_policy: Arc<NetworkPolicy>,
142        secrets: Arc<SecretsConfig>,
143        tls_state: Option<Arc<TlsState>>,
144        proxy_connect: Arc<ProxyConnectState>,
145        outbound_proxy: Option<Arc<ResolvedOutboundProxy>>,
146    ) -> Self {
147        Self {
148            guest_dst,
149            connect_target,
150            from_smoltcp,
151            to_smoltcp,
152            shared,
153            network_policy,
154            secrets,
155            tls_state,
156            proxy_connect,
157            outbound_proxy,
158        }
159    }
160
161    /// Run the TCP proxy task to completion.
162    pub(crate) async fn run(self) {
163        let guest_dst = self.guest_dst;
164        let connect_dst = self.connect_target.primary();
165
166        if let Err(error) = self.try_run().await {
167            tracing::debug!(
168                dst = %connect_dst,
169                %guest_dst,
170                %error,
171                "TCP proxy task ended",
172            );
173        }
174    }
175
176    /// Drive the TCP proxy to completion, returning operational failures.
177    async fn try_run(self) -> io::Result<()> {
178        let Self {
179            guest_dst,
180            connect_target,
181            mut from_smoltcp,
182            to_smoltcp,
183            shared,
184            network_policy,
185            secrets,
186            tls_state,
187            proxy_connect,
188            outbound_proxy,
189        } = self;
190
191        // Pre-connect peek is only for domain policy: the hostname has to be known
192        // before we dial upstream so a Deny never opens a connection. Secrets do
193        // *not* gate the connect, so they no longer force a peek here — that work is
194        // deferred to `classify_first_flight` after the socket is open, where it can
195        // run without stalling server-first protocols (see below).
196        let (mut initial_buf, sni) = if network_policy.has_domain_rules() {
197            peek_for_sni(&mut from_smoltcp, PEEK_BUF_SIZE, PEEK_BUDGET).await
198        } else {
199            (Vec::new(), None)
200        };
201
202        // Re-evaluate egress against the *guest* dst — the address the
203        // guest dialed, not the post-rewrite host-side address. SNI
204        // refines over-allow when the cache matched a shared CDN IP;
205        // CacheOnly is the non-TLS fallback path so Domain rules still
206        // gate plain HTTP / SSH / etc.
207        if network_policy.has_domain_rules() {
208            let source = match sni.as_deref() {
209                Some(name) => HostnameSource::Sni(name),
210                None => HostnameSource::CacheOnly,
211            };
212            match network_policy.evaluate_egress_with_source(
213                guest_dst,
214                Protocol::Tcp,
215                &shared,
216                source,
217            ) {
218                EgressEvaluation::Allow => {}
219                EgressEvaluation::Deny => {
220                    tracing::debug!(
221                        dst = %guest_dst,
222                        source = source.label(),
223                        "TCP egress denied by domain policy",
224                    );
225                    proxy_connect.mark_policy_denied();
226                    shared.proxy_wake.wake();
227                    return Ok(());
228                }
229                EgressEvaluation::DeferUntilHostname => {
230                    debug_assert!(false, "DeferUntilHostname leaked into TCP proxy task");
231                    proxy_connect.mark_policy_denied();
232                    shared.proxy_wake.wake();
233                    return Ok(());
234                }
235            }
236        }
237
238        // Peek for HTTP CONNECT before dialing upstream; hand off if detected.
239        if let Some(tls_state) = tls_state.clone() {
240            if initial_buf.is_empty() {
241                let (peeked, _) = peek_for_sni(&mut from_smoltcp, PEEK_BUF_SIZE, PEEK_BUDGET).await;
242                initial_buf = peeked;
243            }
244            if could_be_connect_request(&initial_buf) {
245                return handle_connect_tunnel(
246                    guest_dst,
247                    connect_target,
248                    initial_buf,
249                    from_smoltcp,
250                    to_smoltcp,
251                    shared,
252                    network_policy,
253                    tls_state,
254                    proxy_connect,
255                    outbound_proxy,
256                    None,
257                )
258                .await;
259            }
260        }
261
262        // Connect upstream *before* finishing the secrets-side classification. A
263        // server-first protocol (SSH, SMTP, a database) sends nothing until it has
264        // seen the server's banner; with the socket already open we can relay that
265        // banner while we wait, instead of burning the peek budget pre-connect.
266        let stream = connect_target
267            .connect(&proxy_connect, &shared, outbound_proxy.as_deref())
268            .await?;
269        let connect_dst = stream.peer_addr().unwrap_or(connect_target.primary());
270        let (mut server_rx, mut server_tx) = stream.into_split();
271
272        // Finish classifying the first flight (TLS vs plain HTTP) and, for
273        // plain-HTTP candidates, gather a full header block — without blocking the
274        // server→guest direction. When domain rules already peeked, `initial_buf`
275        // is reused and this is cheap; with no secrets it is skipped entirely
276        // (`is_tls` only matters for deciding whether to build the handler).
277        let want_headers = secrets.has_plain_http_candidates() || secrets.has_host_scoped_secrets();
278        let (initial_buf, is_tls) = if !secrets.secrets.is_empty() {
279            classify_first_flight(
280                initial_buf,
281                &mut from_smoltcp,
282                &mut server_rx,
283                &to_smoltcp,
284                &shared,
285                want_headers,
286                PEEK_BUF_SIZE,
287                PEEK_BUDGET,
288            )
289            .await?
290        } else {
291            (initial_buf, false)
292        };
293
294        if let Some(tls_state) = tls_state.clone()
295            && could_be_connect_request(&initial_buf)
296        {
297            // The pre-connect CONNECT peek can miss a client whose first bytes arrive
298            // after we dial upstream. Once classify_first_flight has captured that
299            // request, rejoin the already-open proxy socket and use the CONNECT path
300            // so intercepted tunnels still get TLS substitution and policy checks.
301            let proxy_stream = server_rx
302                .reunite(server_tx)
303                .map_err(|_| io::Error::other("failed to reunite proxy stream halves"))?;
304            return handle_connect_tunnel(
305                guest_dst,
306                connect_target,
307                initial_buf,
308                from_smoltcp,
309                to_smoltcp,
310                shared,
311                network_policy,
312                tls_state,
313                proxy_connect,
314                outbound_proxy,
315                Some(proxy_stream),
316            )
317            .await;
318        }
319
320        let mut late_connect_state = tls_state;
321        let mut secrets_handler: Option<SecretsHandler> = if !secrets.secrets.is_empty() && !is_tls
322        {
323            Some(match extract_http_host(&initial_buf) {
324                Some(host) => {
325                    SecretsHandler::new_plain_http(&secrets, &host, guest_dst.ip(), &shared)
326                }
327                None => SecretsHandler::new_plain_http_invalid_host(&secrets),
328            })
329        } else {
330            None
331        };
332
333        // Replay the buffered first flight — run through secrets handler first.
334        if !initial_buf.is_empty() {
335            let out: Cow<[u8]> = match secrets_handler.as_mut() {
336                Some(h) => match h.substitute(&initial_buf) {
337                    // Borrow the input when nothing was substituted; only a chunk
338                    // that actually carries a placeholder is reallocated.
339                    Ok(cow) => cow,
340                    Err(action) => {
341                        tracing::warn!(dst = %connect_dst, violation = ?action, "secret violation in first flight");
342                        if matches!(action, ViolationAction::BlockAndTerminate) {
343                            shared.trigger_termination();
344                        }
345                        return Ok(());
346                    }
347                },
348                None => Cow::Borrowed(&initial_buf),
349            };
350            if !out.is_empty() {
351                if let Err(e) = server_tx.write_all(&out).await {
352                    tracing::debug!(dst = %connect_dst, error = %e, "replay of buffered first flight failed");
353                    return Ok(());
354                }
355                if let Err(e) = server_tx.flush().await {
356                    tracing::debug!(dst = %connect_dst, error = %e, "flush after first flight failed");
357                    return Ok(());
358                }
359            }
360        }
361
362        let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
363
364        // Bidirectional relay using tokio::select!.
365        //
366        // guest → server: receive from channel, write to server socket.
367        // server → guest: read from server socket, send via channel + wake poll.
368        let mut guest_eof = false;
369        loop {
370            tokio::select! {
371                // Guest → server: substitute placeholders before forwarding.
372                data = from_smoltcp.recv(), if !guest_eof => {
373                    match data {
374                        Some(bytes) => {
375                            if let Some(tls_state) = late_connect_state.take()
376                                && could_be_connect_request(&bytes)
377                            {
378                                // The first guest bytes can arrive after both peek
379                                // windows have completed. Nothing has been written
380                                // to the proxy socket yet, so this is still a valid
381                                // point to switch into CONNECT tunnel handling.
382                                let proxy_stream = server_rx
383                                    .reunite(server_tx)
384                                    .map_err(|_| io::Error::other("failed to reunite proxy stream halves"))?;
385                                return handle_connect_tunnel(
386                                    guest_dst,
387                                    connect_target,
388                                    bytes.to_vec(),
389                                    from_smoltcp,
390                                    to_smoltcp,
391                                    shared,
392                                    network_policy,
393                                    tls_state,
394                                    proxy_connect,
395                                    outbound_proxy,
396                                    Some(proxy_stream),
397                                )
398                                .await;
399                            }
400                            // No handler (no secrets / TLS) is the common path: forward
401                            // the chunk borrowed, with no per-chunk allocation or copy.
402                            let out: Cow<[u8]> = match secrets_handler.as_mut() {
403                                Some(h) => match h.substitute(&bytes) {
404                                    Ok(cow) => cow,
405                                    Err(action) => {
406                                        tracing::warn!(dst = %connect_dst, violation = ?action, "secret violation");
407                                        if matches!(action, ViolationAction::BlockAndTerminate) {
408                                            shared.trigger_termination();
409                                        }
410                                        break;
411                                    }
412                                },
413                                None => Cow::Borrowed(&bytes),
414                            };
415                            if !out.is_empty() {
416                                if let Err(e) = server_tx.write_all(&out).await {
417                                    tracing::debug!(dst = %connect_dst, error = %e, "write to server failed");
418                                    break;
419                                }
420                                if let Err(e) = server_tx.flush().await {
421                                    tracing::debug!(dst = %connect_dst, error = %e, "flush to server failed");
422                                    break;
423                                }
424                            }
425                        }
426                        // Channel closed — the guest half-closed (FIN) or the
427                        // connection was torn down. Propagate the half-close:
428                        // stop sending upstream but keep relaying server →
429                        // guest until the server closes.
430                        None => {
431                            guest_eof = true;
432                            if server_tx.shutdown().await.is_err() {
433                                break;
434                            }
435                        }
436                    }
437                }
438
439                // Server → guest: no substitution — server never sends placeholders.
440                result = server_rx.read(&mut server_buf) => {
441                    match result {
442                        Ok(0) => break, // Server closed connection.
443                        Ok(n) => {
444                            // A server-first byte means this is not an HTTP CONNECT
445                            // tunnel to a proxy. Keep relaying normally afterward.
446                            late_connect_state = None;
447                            let data = Bytes::copy_from_slice(&server_buf[..n]);
448                            if to_smoltcp.send(data).await.is_err() {
449                                // Channel closed — poll loop dropped the receiver.
450                                break;
451                            }
452                            // Wake the poll thread so it writes data to the
453                            // smoltcp socket.
454                            shared.proxy_wake.wake();
455                        }
456                        Err(e) => {
457                            tracing::debug!(dst = %connect_dst, error = %e, "read from server failed");
458                            break;
459                        }
460                    }
461                }
462            }
463        }
464
465        Ok(())
466    }
467}
468
469//--------------------------------------------------------------------------------------------------
470// Functions
471//--------------------------------------------------------------------------------------------------
472
473/// Spawn a TCP proxy task for a newly established connection.
474///
475/// `guest_dst` is what the guest dialed — the address policy rules match
476/// against. `connect_dst` is the host-side address tokio actually dials.
477///
478/// `proxy_connect` is updated before the task exits so the connection
479/// tracker can decide between FIN (clean close) and RST (upstream
480/// connect failure).
481#[allow(clippy::too_many_arguments)]
482pub fn spawn_tcp_proxy(
483    handle: &tokio::runtime::Handle,
484    guest_dst: SocketAddr,
485    connect_dst: SocketAddr,
486    from_smoltcp: mpsc::Receiver<Bytes>,
487    to_smoltcp: mpsc::Sender<Bytes>,
488    shared: Arc<SharedState>,
489    network_policy: Arc<NetworkPolicy>,
490    secrets: Arc<SecretsConfig>,
491    tls_state: Option<Arc<TlsState>>,
492    proxy_connect: Arc<ProxyConnectState>,
493    outbound_proxy: Option<Arc<ResolvedOutboundProxy>>,
494) {
495    let proxy = TcpProxy::new(
496        guest_dst,
497        UpstreamTcpTarget::direct(connect_dst),
498        from_smoltcp,
499        to_smoltcp,
500        shared,
501        network_policy,
502        secrets,
503        tls_state,
504        proxy_connect,
505        outbound_proxy,
506    );
507
508    handle.spawn(proxy.run());
509}
510
511/// Forward an HTTP CONNECT tunnel: dial the proxy, splice the handshake,
512/// then hand the established stream to [`TlsProxy`] for TLS MITM.
513///
514/// `guest_dst` is what the guest dialed; `proxy_target` contains the rewritten
515/// loopback address the gateway actually connects to and its optional fallback.
516#[allow(clippy::too_many_arguments)]
517async fn handle_connect_tunnel(
518    guest_dst: SocketAddr,
519    proxy_target: UpstreamTcpTarget,
520    initial_buf: Vec<u8>,
521    mut from_smoltcp: mpsc::Receiver<Bytes>,
522    to_smoltcp: mpsc::Sender<Bytes>,
523    shared: Arc<SharedState>,
524    network_policy: Arc<NetworkPolicy>,
525    tls_state: Arc<TlsState>,
526    proxy_connect: Arc<ProxyConnectState>,
527    outbound_proxy: Option<Arc<ResolvedOutboundProxy>>,
528    preconnected_proxy: Option<TcpStream>,
529) -> io::Result<()> {
530    let proxy_dst = proxy_target.primary();
531    let connect_req =
532        parse_connect_request(buffer_connect_request(initial_buf, &mut from_smoltcp).await?)?;
533
534    let connect_headers = match sanitize_connect_headers(
535        connect_req.header_bytes(),
536        &tls_state.secrets.load(),
537    ) {
538        Ok(headers) => headers,
539        Err(action) => {
540            tracing::warn!(dst = %proxy_dst, violation = ?action, "secret violation in CONNECT headers");
541            if matches!(action, ViolationAction::BlockAndTerminate) {
542                shared.trigger_termination();
543            }
544            return Ok(());
545        }
546    };
547
548    // Dial the proxy and forward the CONNECT request so it opens the tunnel.
549    let mut proxy_stream = match preconnected_proxy {
550        Some(stream) => stream,
551        None => {
552            proxy_target
553                .connect(&proxy_connect, &shared, outbound_proxy.as_deref())
554                .await?
555        }
556    };
557
558    if !connect_req.target.is_intercepted(&tls_state) {
559        proxy_stream.write_all(&connect_headers).await?;
560        proxy_stream.flush().await?;
561        let (proxy_resp, header_end) = read_connect_response_headers(&mut proxy_stream).await?;
562        if to_smoltcp
563            .send(Bytes::copy_from_slice(&proxy_resp[..header_end]))
564            .await
565            .is_err()
566        {
567            return Ok(());
568        }
569        if !proxy_resp[header_end..].is_empty()
570            && to_smoltcp
571                .send(Bytes::copy_from_slice(&proxy_resp[header_end..]))
572                .await
573                .is_err()
574        {
575            return Ok(());
576        }
577        shared.proxy_wake.wake();
578        if !connect_response_is_success(&proxy_resp[..header_end]) {
579            proxy_connect.mark_connected();
580            return Ok(());
581        }
582        if !connect_req.post_header_bytes().is_empty() {
583            proxy_stream
584                .write_all(connect_req.post_header_bytes())
585                .await?;
586        }
587        proxy_stream.flush().await?;
588        proxy_connect.mark_connected();
589        return relay_connected_stream(proxy_stream, from_smoltcp, to_smoltcp, shared).await;
590    }
591
592    proxy_stream.write_all(&connect_headers).await?;
593    proxy_stream.flush().await?;
594
595    let (proxy_resp, header_end) = read_connect_response_headers(&mut proxy_stream).await?;
596    if !connect_response_is_success(&proxy_resp[..header_end]) {
597        return Err(io::Error::new(
598            io::ErrorKind::ConnectionRefused,
599            "proxy rejected CONNECT",
600        ));
601    }
602    if !proxy_resp[header_end..].is_empty() {
603        return Err(io::Error::new(
604            io::ErrorKind::InvalidData,
605            "proxy sent unexpected bytes after CONNECT response headers",
606        ));
607    }
608    proxy_connect.mark_connected();
609
610    if to_smoltcp
611        .send(Bytes::copy_from_slice(&proxy_resp[..header_end]))
612        .await
613        .is_err()
614    {
615        return Ok(());
616    }
617    shared.proxy_wake.wake();
618
619    let tls_seed = connect_req.post_header_bytes().to_vec();
620    let tls_guest_dst = connect_req.target.guest_dst(guest_dst, &shared);
621    let expected_sni = connect_req.target.expected_sni.clone();
622
623    TlsProxy::new(
624        tls_guest_dst,
625        proxy_target,
626        from_smoltcp,
627        to_smoltcp,
628        shared,
629        tls_state,
630        network_policy,
631        proxy_connect,
632        // Unused: `upstream_stream` is already `Some` below, so the
633        // outbound proxy (already applied when dialing `proxy_stream`
634        // above) is never consulted again.
635        None,
636    )
637    .with_upstream(proxy_stream)
638    .with_expected_sni(expected_sni)
639    .with_initial_buf(tls_seed)
640    .try_run()
641    .await
642}
643
644/// Relay an established TCP stream without inspecting or substituting bytes.
645async fn relay_connected_stream(
646    stream: TcpStream,
647    mut from_smoltcp: mpsc::Receiver<Bytes>,
648    to_smoltcp: mpsc::Sender<Bytes>,
649    shared: Arc<SharedState>,
650) -> io::Result<()> {
651    let (mut server_rx, mut server_tx) = stream.into_split();
652    let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
653
654    let mut guest_eof = false;
655    loop {
656        tokio::select! {
657            data = from_smoltcp.recv(), if !guest_eof => {
658                match data {
659                    Some(bytes) => {
660                        server_tx.write_all(&bytes).await?;
661                        server_tx.flush().await?;
662                    }
663                    // Guest half-closed (FIN): stop sending upstream but
664                    // keep relaying server → guest until the server closes.
665                    None => {
666                        guest_eof = true;
667                        if server_tx.shutdown().await.is_err() {
668                            break;
669                        }
670                    }
671                }
672            }
673            result = server_rx.read(&mut server_buf) => {
674                match result {
675                    Ok(0) => break,
676                    Ok(n) => {
677                        if to_smoltcp
678                            .send(Bytes::copy_from_slice(&server_buf[..n]))
679                            .await
680                            .is_err()
681                        {
682                            break;
683                        }
684                        shared.proxy_wake.wake();
685                    }
686                    Err(e) => return Err(e),
687                }
688            }
689        }
690    }
691
692    Ok(())
693}
694
695async fn buffer_connect_request(
696    mut buf: Vec<u8>,
697    from_smoltcp: &mut mpsc::Receiver<Bytes>,
698) -> io::Result<Vec<u8>> {
699    let timeout_fut = tokio::time::sleep(PEEK_BUDGET);
700    tokio::pin!(timeout_fut);
701
702    loop {
703        if !could_be_connect_request(&buf) {
704            return Err(io::Error::new(
705                io::ErrorKind::InvalidData,
706                "malformed CONNECT request prefix",
707            ));
708        }
709        if headers_end(&buf).is_some() {
710            return Ok(buf);
711        }
712        if buf.len() >= PEEK_BUF_SIZE {
713            return Err(io::Error::new(
714                io::ErrorKind::InvalidData,
715                "CONNECT request headers too large",
716            ));
717        }
718
719        tokio::select! {
720            biased;
721            _ = &mut timeout_fut => {
722                return Err(io::Error::new(
723                    io::ErrorKind::TimedOut,
724                    "timed out waiting for complete CONNECT request headers",
725                ));
726            }
727            data = from_smoltcp.recv() => match data {
728                Some(bytes) => {
729                    buf.extend_from_slice(&bytes);
730                }
731                None => {
732                    return Err(io::Error::new(
733                        io::ErrorKind::UnexpectedEof,
734                        "channel closed before complete CONNECT request headers",
735                    ));
736                }
737            }
738        }
739    }
740}
741
742async fn read_connect_response_headers(stream: &mut TcpStream) -> io::Result<(Vec<u8>, usize)> {
743    tokio::time::timeout(PEEK_BUDGET, async {
744        let mut proxy_resp = Vec::with_capacity(256);
745        let mut buf = [0u8; 4096];
746        loop {
747            let n = stream.read(&mut buf).await?;
748            if n == 0 {
749                return Err(io::Error::new(
750                    io::ErrorKind::UnexpectedEof,
751                    "proxy closed before sending CONNECT response",
752                ));
753            }
754            proxy_resp.extend_from_slice(&buf[..n]);
755            if let Some(end) = headers_end(&proxy_resp) {
756                return Ok((proxy_resp, end));
757            }
758            if proxy_resp.len() > CONNECT_RESP_LIMIT {
759                return Err(io::Error::new(
760                    io::ErrorKind::InvalidData,
761                    "proxy CONNECT response too large",
762                ));
763            }
764        }
765    })
766    .await
767    .map_err(|_| {
768        io::Error::new(
769            io::ErrorKind::TimedOut,
770            "timed out waiting for proxy CONNECT response",
771        )
772    })?
773}
774
775fn sanitize_connect_headers<'a>(
776    header_bytes: &'a [u8],
777    secrets: &SecretsConfig,
778) -> Result<Cow<'a, [u8]>, ViolationAction> {
779    if secrets.secrets.is_empty() {
780        return Ok(Cow::Borrowed(header_bytes));
781    }
782
783    let mut handler = SecretsHandler::new_plain_http_untrusted_metadata(secrets);
784    handler.substitute(header_bytes)
785}
786
787/// Returns the byte offset just past the `\r\n\r\n` header terminator, or `None`.
788fn headers_end(buf: &[u8]) -> Option<usize> {
789    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
790}
791
792fn could_be_connect_request(buf: &[u8]) -> bool {
793    const PREFIX: &[u8] = b"CONNECT ";
794    if buf.is_empty() {
795        return false;
796    }
797    let n = buf.len().min(PREFIX.len());
798    buf[..n].eq_ignore_ascii_case(&PREFIX[..n])
799}
800
801fn parse_connect_request(bytes: Vec<u8>) -> io::Result<ConnectRequest> {
802    let header_end = headers_end(&bytes).ok_or_else(|| {
803        io::Error::new(
804            io::ErrorKind::InvalidData,
805            "incomplete CONNECT request headers",
806        )
807    })?;
808    let target = {
809        let request_line = bytes[..header_end]
810            .split(|&b| b == b'\n')
811            .next()
812            .unwrap_or(&[]);
813        let request_line = std::str::from_utf8(request_line)
814            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "CONNECT line is not UTF-8"))?
815            .trim_end_matches('\r');
816        let mut parts = request_line.split_ascii_whitespace();
817        let method = parts.next().unwrap_or_default();
818        let authority = parts.next().unwrap_or_default();
819        let version = parts.next().unwrap_or_default();
820        if !method.eq_ignore_ascii_case("CONNECT")
821            || authority.is_empty()
822            || !is_http_version(version)
823            || parts.next().is_some()
824        {
825            return Err(io::Error::new(
826                io::ErrorKind::InvalidData,
827                "malformed CONNECT request line",
828            ));
829        }
830        parse_connect_target(authority)?
831    };
832
833    Ok(ConnectRequest {
834        bytes,
835        header_end,
836        target,
837    })
838}
839
840fn parse_connect_target(authority: &str) -> io::Result<ConnectTarget> {
841    let authority = authority.trim();
842    let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
843        let (host, rest) = rest.split_once(']').ok_or_else(|| {
844            io::Error::new(
845                io::ErrorKind::InvalidData,
846                "malformed CONNECT IPv6 authority",
847            )
848        })?;
849        let port = rest.strip_prefix(':').ok_or_else(|| {
850            io::Error::new(io::ErrorKind::InvalidData, "CONNECT authority missing port")
851        })?;
852        (host, port)
853    } else {
854        let (host, port) = authority.rsplit_once(':').ok_or_else(|| {
855            io::Error::new(io::ErrorKind::InvalidData, "CONNECT authority missing port")
856        })?;
857        if host.contains(':') {
858            return Err(io::Error::new(
859                io::ErrorKind::InvalidData,
860                "CONNECT IPv6 authority must be bracketed",
861            ));
862        }
863        (host, port)
864    };
865    let host = host.trim().trim_end_matches('.');
866    if host.is_empty() {
867        return Err(io::Error::new(
868            io::ErrorKind::InvalidData,
869            "CONNECT authority missing host",
870        ));
871    }
872    let port = port
873        .parse::<u16>()
874        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid CONNECT port"))?;
875    let expected_sni = host
876        .parse::<IpAddr>()
877        .is_err()
878        .then(|| host.to_ascii_lowercase());
879
880    Ok(ConnectTarget {
881        host: host.to_ascii_lowercase(),
882        port,
883        expected_sni,
884    })
885}
886
887fn is_http_version(version: &str) -> bool {
888    let Some(version) = version.strip_prefix("HTTP/") else {
889        return false;
890    };
891    let Some((major, minor)) = version.split_once('.') else {
892        return false;
893    };
894    !major.is_empty()
895        && !minor.is_empty()
896        && major.bytes().all(|b| b.is_ascii_digit())
897        && minor.bytes().all(|b| b.is_ascii_digit())
898}
899
900fn connect_response_is_success(headers: &[u8]) -> bool {
901    let Some(status_line) = headers.split(|&b| b == b'\n').next() else {
902        return false;
903    };
904    let Ok(status_line) = std::str::from_utf8(status_line) else {
905        return false;
906    };
907    let mut parts = status_line.trim_end_matches('\r').split_ascii_whitespace();
908    let version = parts.next().unwrap_or_default();
909    let status = parts.next().unwrap_or_default();
910    is_http_version(version)
911        && status.len() == 3
912        && status
913            .parse::<u16>()
914            .is_ok_and(|code| (200..300).contains(&code))
915}
916
917/// Extract the `Host:` header value from an already-buffered HTTP header block.
918///
919/// Returns `None` if:
920/// - The first byte is `0x16` (TLS — not HTTP)
921/// - The buffer does not yet contain `\r\n\r\n` (headers incomplete)
922/// - No `Host:` header is present
923///
924/// Strips port suffix, lowercases, and trims whitespace. Result is
925/// ready for byte-equal matching against `SecretEntry::allowed_hosts`.
926fn extract_http_host(buf: &[u8]) -> Option<String> {
927    if buf.first() == Some(&0x16) {
928        return None;
929    }
930    // Size the header pool to the buffer rather than a fixed array: a header
931    // line is at least four bytes (`a:\r\n`), so `len / 4` always covers the
932    // real header count, and `httparse` never reports `TooManyHeaders` (which
933    // would make a request with many headers look hostless). The first flight
934    // is capped at PEEK_BUF_SIZE, so this stays bounded.
935    let mut headers = vec![httparse::EMPTY_HEADER; (buf.len() / 4).max(16)];
936    let mut req = httparse::Request::new(&mut headers);
937    req.parse(buf).ok()?;
938    req.headers
939        .iter()
940        .find(|h| h.name.eq_ignore_ascii_case("host"))
941        .and_then(|h| std::str::from_utf8(h.value).ok())
942        .map(|v| {
943            let host = v.trim();
944            // Strip port suffix.
945            host.rsplit_once(':')
946                .map(|(h, _)| h)
947                .unwrap_or(host)
948                .to_ascii_lowercase()
949        })
950        .filter(|h| !h.is_empty())
951}
952
953/// Finish classifying the guest's first flight after the upstream socket is
954/// open, returning the (possibly extended) first-flight buffer and whether it
955/// is a TLS record.
956///
957/// `buf` carries whatever a pre-connect domain-rule peek already captured; when
958/// it is non-empty the TLS/plain decision is already settled and only header
959/// top-up runs. `want_headers` is set when at least one secret can be
960/// substituted over plain HTTP (`SecretsConfig::has_plain_http_candidates`); it
961/// makes the peek keep reading a non-TLS flight until `\r\n\r\n` so
962/// [`extract_http_host`] sees a complete header block.
963///
964/// Crucially, this relays server→guest while it waits. Server-first protocols
965/// (SSH, SMTP, databases) send nothing until they have seen the server's
966/// banner; draining the server side here lets the banner reach the guest
967/// immediately, so the guest's eventual first flight — not a 5s timeout — is
968/// what ends the peek.
969#[allow(clippy::too_many_arguments)]
970async fn classify_first_flight(
971    mut buf: Vec<u8>,
972    from_smoltcp: &mut mpsc::Receiver<Bytes>,
973    server_rx: &mut tokio::net::tcp::OwnedReadHalf,
974    to_smoltcp: &mpsc::Sender<Bytes>,
975    shared: &SharedState,
976    want_headers: bool,
977    max: usize,
978    budget: Duration,
979) -> io::Result<(Vec<u8>, bool)> {
980    let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
981    let timeout_fut = tokio::time::sleep(budget);
982    tokio::pin!(timeout_fut);
983
984    loop {
985        // Stop as soon as the protocol class is known and — for plain-HTTP
986        // candidates — a full header block has arrived. Bail the moment a
987        // non-TLS flight stops looking like an HTTP request so non-HTTP
988        // protocols (SSH, Postgres) aren't withheld from upstream for the
989        // whole budget while we wait for a `\r\n\r\n` that never comes.
990        if !buf.is_empty() {
991            let is_tls = buf.first() == Some(&0x16);
992            let not_http = !is_tls
993                && (!looks_like_http_request_prefix(&buf) || first_line_is_not_http_request(&buf));
994            let done = !want_headers
995                || is_tls
996                || not_http
997                || buf.len() >= max
998                || buf.windows(4).any(|w| w == b"\r\n\r\n");
999            if done {
1000                return Ok((buf, is_tls));
1001            }
1002        }
1003
1004        tokio::select! {
1005            biased;
1006            _ = &mut timeout_fut => {
1007                let is_tls = buf.first() == Some(&0x16);
1008                return Ok((buf, is_tls));
1009            }
1010            // Guest → buffer (not forwarded here; the caller replays it once the
1011            // handler is built, so substitution applies to the first flight too).
1012            guest = from_smoltcp.recv() => match guest {
1013                Some(bytes) => buf.extend_from_slice(&bytes),
1014                None => {
1015                    let is_tls = buf.first() == Some(&0x16);
1016                    return Ok((buf, is_tls));
1017                }
1018            },
1019            // Server → guest: relay immediately so a server-first banner is never
1020            // held hostage by the peek.
1021            server = server_rx.read(&mut server_buf) => match server {
1022                Ok(0) => {
1023                    let is_tls = buf.first() == Some(&0x16);
1024                    return Ok((buf, is_tls));
1025                }
1026                Ok(n) => {
1027                    let data = Bytes::copy_from_slice(&server_buf[..n]);
1028                    if to_smoltcp.send(data).await.is_err() {
1029                        let is_tls = buf.first() == Some(&0x16);
1030                        return Ok((buf, is_tls));
1031                    }
1032                    shared.proxy_wake.wake();
1033                }
1034                Err(e) => return Err(e),
1035            },
1036        }
1037    }
1038}
1039
1040/// Buffer the first flight until SNI can be extracted, or until one
1041/// of the bail-out conditions hits (channel close, buffer cap,
1042/// timeout). Never errors; non-TLS / slow / malformed input all
1043/// fall through to `None`.
1044///
1045/// On hit, the SNI is canonicalized (lowercase + trim trailing dot)
1046/// for byte-equal matching against rule destinations. The returned
1047/// buffer must be replayed verbatim to upstream before the caller
1048/// starts its relay loop.
1049async fn peek_for_sni(
1050    rx: &mut mpsc::Receiver<Bytes>,
1051    max: usize,
1052    budget: Duration,
1053) -> (Vec<u8>, Option<String>) {
1054    let mut buf = Vec::with_capacity(PEEK_BUF_SIZE.min(8192));
1055    let timeout_fut = tokio::time::sleep(budget);
1056    tokio::pin!(timeout_fut);
1057
1058    let raw_sni = loop {
1059        tokio::select! {
1060            biased;
1061            _ = &mut timeout_fut => break None,
1062            data = rx.recv() => {
1063                match data {
1064                    Some(bytes) => {
1065                        buf.extend_from_slice(&bytes);
1066                        // First byte of a TLS record is the ContentType;
1067                        // 0x16 is handshake. Anything else can't be a
1068                        // ClientHello, so don't burn the full budget on
1069                        // plain HTTP / SSH / etc.
1070                        if buf.first() != Some(&0x16) {
1071                            break None;
1072                        }
1073                        if let Some(name) = sni::extract_sni(&buf) {
1074                            break Some(name);
1075                        }
1076                        if buf.len() >= max {
1077                            break None;
1078                        }
1079                    }
1080                    None => break None,
1081                }
1082            }
1083        }
1084    };
1085
1086    let canonical = raw_sni.map(|s| s.trim_end_matches('.').to_ascii_lowercase());
1087    (buf, canonical)
1088}
1089
1090//--------------------------------------------------------------------------------------------------
1091// Tests
1092//--------------------------------------------------------------------------------------------------
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097
1098    /// Synthetic TLS ClientHello carrying SNI `example.com`. Bytes
1099    /// borrowed from `tls::sni` test fixtures so the parser sees a
1100    /// well-formed record.
1101    fn synthetic_client_hello(sni: &str) -> Vec<u8> {
1102        // Minimal but valid TLS 1.2 ClientHello with one SNI entry.
1103        // Layout: record header (5) + handshake header (4) + body.
1104        let host_bytes = sni.as_bytes();
1105        let host_len = host_bytes.len() as u16;
1106        let server_name_list_len = 3 + host_len; // type(1) + len(2) + host
1107        let extension_data_len = 2 + server_name_list_len; // list-len(2) + list
1108        let extensions_total = 4 + extension_data_len; // type(2) + len(2) + data
1109
1110        let mut body = Vec::new();
1111        // Client version
1112        body.extend_from_slice(&[0x03, 0x03]);
1113        // Random (32 bytes)
1114        body.extend_from_slice(&[0u8; 32]);
1115        // Session id length + (empty)
1116        body.push(0);
1117        // Cipher suites length + one cipher
1118        body.extend_from_slice(&[0x00, 0x02, 0x00, 0x2f]);
1119        // Compression methods length + null
1120        body.extend_from_slice(&[0x01, 0x00]);
1121        // Extensions length
1122        body.extend_from_slice(&extensions_total.to_be_bytes());
1123        // SNI extension: type 0x0000
1124        body.extend_from_slice(&[0x00, 0x00]);
1125        body.extend_from_slice(&extension_data_len.to_be_bytes());
1126        body.extend_from_slice(&server_name_list_len.to_be_bytes());
1127        body.push(0x00); // host_name type
1128        body.extend_from_slice(&host_len.to_be_bytes());
1129        body.extend_from_slice(host_bytes);
1130
1131        let handshake_len = body.len() as u32;
1132        let mut hs = Vec::new();
1133        hs.push(0x01); // ClientHello
1134        hs.extend_from_slice(&handshake_len.to_be_bytes()[1..]); // 24-bit length
1135        hs.extend_from_slice(&body);
1136
1137        let record_len = hs.len() as u16;
1138        let mut record = Vec::new();
1139        record.extend_from_slice(&[0x16, 0x03, 0x01]); // Handshake, TLS 1.0
1140        record.extend_from_slice(&record_len.to_be_bytes());
1141        record.extend_from_slice(&hs);
1142
1143        record
1144    }
1145
1146    #[tokio::test]
1147    async fn connect_upstream_dials_target_directly_without_outbound_proxy() {
1148        use tokio::net::TcpListener;
1149
1150        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1151        let addr = listener.local_addr().unwrap();
1152        let accept = tokio::spawn(async move {
1153            let (mut sock, _) = listener.accept().await.unwrap();
1154            let mut buf = [0u8; 5];
1155            sock.read_exact(&mut buf).await.unwrap();
1156            assert_eq!(&buf, b"hello");
1157        });
1158
1159        let shared = SharedState::new(4);
1160        let proxy_connect = ProxyConnectState::new();
1161        let mut stream = UpstreamTcpTarget::direct(addr)
1162            .connect(&proxy_connect, &shared, None)
1163            .await
1164            .unwrap();
1165        stream.write_all(b"hello").await.unwrap();
1166
1167        accept.await.unwrap();
1168        assert!(matches!(
1169            proxy_connect.status(),
1170            ProxyConnectStatus::Connected
1171        ));
1172    }
1173
1174    #[tokio::test]
1175    async fn early_http_connect_dials_proxy_through_configured_socks5_proxy() {
1176        let _ = rustls::crypto::ring::default_provider().install_default();
1177
1178        // This is the HTTP proxy the guest originally dialed. It is never
1179        // contacted directly; the SOCKS5 request below must carry this address.
1180        let http_proxy_addr: SocketAddr = "93.184.216.34:3128".parse().unwrap();
1181        let socks_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1182        let outbound_proxy = ResolvedOutboundProxy::Socks5 {
1183            address: socks_listener.local_addr().unwrap(),
1184            credentials: None,
1185        };
1186        let socks_task = tokio::spawn(async move {
1187            let (mut client, _) = socks_listener.accept().await.unwrap();
1188
1189            let mut greeting = [0u8; 3];
1190            client.read_exact(&mut greeting).await.unwrap();
1191            assert_eq!(greeting, [0x05, 0x01, 0x00]);
1192            client.write_all(&[0x05, 0x00]).await.unwrap();
1193
1194            let mut socks_request = [0u8; 10];
1195            client.read_exact(&mut socks_request).await.unwrap();
1196            assert_eq!(socks_request[0..4], [0x05, 0x01, 0x00, 0x01]);
1197            assert_eq!(&socks_request[4..8], &[93, 184, 216, 34]);
1198            assert_eq!(
1199                u16::from_be_bytes([socks_request[8], socks_request[9]]),
1200                3128
1201            );
1202            client
1203                .write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
1204                .await
1205                .unwrap();
1206
1207            let expected_connect =
1208                b"CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n";
1209            let mut connect_request = vec![0u8; expected_connect.len()];
1210            client.read_exact(&mut connect_request).await.unwrap();
1211            assert_eq!(&connect_request, expected_connect);
1212            client
1213                .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
1214                .await
1215                .unwrap();
1216        });
1217
1218        let connect_request =
1219            b"CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n".to_vec();
1220        let (from_tx, from_rx) = mpsc::channel(1);
1221        let (to_tx, mut to_rx) = mpsc::channel(1);
1222        drop(from_tx);
1223
1224        let tls_state = Arc::new(
1225            TlsState::new(
1226                microsandbox_types::TlsConfig::default(),
1227                crate::secrets::handle::SecretsHandle::new(SecretsConfig::default()),
1228            )
1229            .unwrap(),
1230        );
1231        let proxy_connect = Arc::new(ProxyConnectState::new());
1232
1233        handle_connect_tunnel(
1234            http_proxy_addr,
1235            UpstreamTcpTarget::direct(http_proxy_addr),
1236            connect_request,
1237            from_rx,
1238            to_tx,
1239            Arc::new(SharedState::new(4)),
1240            Arc::new(NetworkPolicy::default()),
1241            tls_state,
1242            proxy_connect.clone(),
1243            Some(Arc::new(outbound_proxy)),
1244            None,
1245        )
1246        .await
1247        .unwrap();
1248
1249        let response = to_rx.recv().await.unwrap();
1250        assert_eq!(
1251            &response[..],
1252            b"HTTP/1.1 200 Connection Established\r\n\r\n"
1253        );
1254        socks_task.await.unwrap();
1255        assert!(matches!(
1256            proxy_connect.status(),
1257            ProxyConnectStatus::Connected
1258        ));
1259    }
1260
1261    #[test]
1262    fn could_be_connect_request_matches_split_prefixes_only() {
1263        assert!(could_be_connect_request(b"C"));
1264        assert!(could_be_connect_request(b"connect "));
1265        assert!(could_be_connect_request(b"CONNECT example.com:443"));
1266        assert!(!could_be_connect_request(b"CLIENT"));
1267        assert!(!could_be_connect_request(b"GET / HTTP/1.1\r\n"));
1268    }
1269
1270    #[tokio::test]
1271    async fn buffer_connect_request_reads_split_headers() {
1272        let (tx, mut rx) = mpsc::channel(4);
1273        tx.send(Bytes::from_static(b"NECT example.com:443 HTTP/1.1\r\n"))
1274            .await
1275            .unwrap();
1276        tx.send(Bytes::from_static(b"Host: example.com\r\n\r\n"))
1277            .await
1278            .unwrap();
1279        drop(tx);
1280
1281        let buffered = buffer_connect_request(b"CON".to_vec(), &mut rx)
1282            .await
1283            .unwrap();
1284        let parsed = parse_connect_request(buffered).unwrap();
1285
1286        assert_eq!(parsed.target.host, "example.com");
1287        assert_eq!(parsed.target.port, 443);
1288        assert_eq!(parsed.target.expected_sni.as_deref(), Some("example.com"));
1289        assert!(parsed.post_header_bytes().is_empty());
1290    }
1291
1292    #[test]
1293    fn parse_connect_request_preserves_post_header_tls_seed() {
1294        let mut request = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n".to_vec();
1295        request.extend_from_slice(b"\x16\x03\x01client-hello");
1296
1297        let parsed = parse_connect_request(request).unwrap();
1298
1299        assert_eq!(
1300            parsed.header_bytes(),
1301            b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n"
1302        );
1303        assert_eq!(parsed.post_header_bytes(), b"\x16\x03\x01client-hello");
1304    }
1305
1306    #[test]
1307    fn parse_connect_target_requires_authority_port() {
1308        assert!(parse_connect_target("example.com").is_err());
1309        assert!(parse_connect_target("2001:db8::1:443").is_err());
1310
1311        let target = parse_connect_target("[2001:db8::1]:8443").unwrap();
1312        assert_eq!(target.host, "2001:db8::1");
1313        assert_eq!(target.port, 8443);
1314        assert_eq!(target.expected_sni, None);
1315    }
1316
1317    #[test]
1318    fn connect_response_success_requires_exact_2xx_status_code() {
1319        assert!(connect_response_is_success(
1320            b"HTTP/1.1 200 Connection Established\r\n\r\n"
1321        ));
1322        assert!(connect_response_is_success(
1323            b"HTTP/1.1 204 Connection Established\r\n\r\n"
1324        ));
1325        assert!(!connect_response_is_success(b"HTTP/1.1 2000 Weird\r\n\r\n"));
1326        assert!(!connect_response_is_success(b"HTTP/1.1 199 Nope\r\n\r\n"));
1327        assert!(!connect_response_is_success(b"NOTHTTP 200 OK\r\n\r\n"));
1328    }
1329
1330    #[tokio::test]
1331    async fn peek_for_sni_extracts_and_canonicalizes() {
1332        let (tx, mut rx) = mpsc::channel(4);
1333        let hello = synthetic_client_hello("Example.COM");
1334        tx.send(Bytes::from(hello.clone())).await.unwrap();
1335        drop(tx); // close so peek returns even if SNI didn't satisfy
1336
1337        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1338        assert_eq!(sni.as_deref(), Some("example.com"));
1339        assert_eq!(buf, hello);
1340    }
1341
1342    #[tokio::test]
1343    async fn peek_for_sni_returns_none_on_channel_close_without_data() {
1344        let (tx, mut rx) = mpsc::channel::<Bytes>(1);
1345        drop(tx);
1346        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1347        assert!(buf.is_empty());
1348        assert_eq!(sni, None);
1349    }
1350
1351    #[tokio::test]
1352    async fn peek_for_sni_returns_none_on_non_tls_data() {
1353        let (tx, mut rx) = mpsc::channel(4);
1354        // Plaintext HTTP request; not a TLS record so extract_sni returns None.
1355        tx.send(Bytes::from_static(
1356            b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
1357        ))
1358        .await
1359        .unwrap();
1360        drop(tx);
1361        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1362        assert!(
1363            !buf.is_empty(),
1364            "buffered bytes must be returned for replay"
1365        );
1366        assert_eq!(sni, None);
1367    }
1368
1369    #[tokio::test]
1370    async fn peek_for_sni_falls_back_on_timeout() {
1371        let (tx, mut rx) = mpsc::channel::<Bytes>(1);
1372        // Hold the sender open but send nothing — peek must time out.
1373        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, Duration::from_millis(50)).await;
1374        drop(tx);
1375        assert!(buf.is_empty());
1376        assert_eq!(sni, None);
1377    }
1378
1379    #[tokio::test]
1380    async fn peek_for_sni_caps_at_max_bytes() {
1381        let (tx, mut rx) = mpsc::channel(4);
1382        // First byte 0x16 keeps the peek collecting past the early
1383        // non-TLS bail. Padding bytes are zero so the SNI parser never
1384        // matches and the loop drives to the size cap.
1385        let mut first = vec![0u8; 8192];
1386        first[0] = 0x16;
1387        tx.send(Bytes::from(first)).await.unwrap();
1388        tx.send(Bytes::from(vec![0u8; 8192])).await.unwrap();
1389        tx.send(Bytes::from(vec![0u8; 8192])).await.unwrap();
1390        drop(tx);
1391
1392        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1393        assert_eq!(sni, None, "no SNI in non-TLS data");
1394        assert!(
1395            buf.len() >= PEEK_BUF_SIZE,
1396            "buffer must hit the cap before bail-out: got {}",
1397            buf.len()
1398        );
1399    }
1400
1401    #[tokio::test]
1402    async fn peek_for_sni_bails_immediately_on_non_tls_first_byte() {
1403        let (tx, mut rx) = mpsc::channel(4);
1404        // Plain HTTP request: first byte 'G' (0x47) — clearly not TLS.
1405        tx.send(Bytes::from_static(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
1406            .await
1407            .unwrap();
1408        drop(tx);
1409
1410        // 5-second nominal budget; assert we returned in well under
1411        // that — the early-bail must not wait for the full window.
1412        let started = std::time::Instant::now();
1413        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1414        let elapsed = started.elapsed();
1415        assert_eq!(sni, None);
1416        assert!(buf.starts_with(b"GET"));
1417        assert!(
1418            elapsed < Duration::from_millis(500),
1419            "non-TLS bail must be fast: took {elapsed:?}"
1420        );
1421    }
1422
1423    //----------------------------------------------------------------------------------------------
1424    // peek_for_sni × evaluate_egress_with_source — combined integration tests
1425    //----------------------------------------------------------------------------------------------
1426
1427    use std::net::IpAddr;
1428    use std::time::Duration as StdDuration;
1429
1430    use crate::netstack::shared::{ResolvedHostnameFamily, SharedState};
1431    use crate::policy::{Action, Destination, NetworkPolicy, PortRange, Rule};
1432
1433    const SHARED_FASTLY_IP: &str = "151.101.0.223";
1434
1435    fn shared_with(host: &str, ip: &str) -> SharedState {
1436        let shared = SharedState::new(4);
1437        shared.cache_resolved_hostname(
1438            host,
1439            ResolvedHostnameFamily::Ipv4,
1440            [ip.parse::<IpAddr>().unwrap()],
1441            StdDuration::from_secs(60),
1442        );
1443        shared
1444    }
1445
1446    fn allow_https(domain: &str) -> Rule {
1447        Rule {
1448            direction: crate::policy::Direction::Egress,
1449            destination: Destination::Domain(domain.parse().unwrap()),
1450            protocols: vec![Protocol::Tcp],
1451            ports: vec![PortRange::single(443)],
1452            action: Action::Allow,
1453        }
1454    }
1455
1456    /// Over-allow case: cache says IP X is `pypi.org` (allowed); SNI
1457    /// is `evil.com`. SNI must override the cache and deny.
1458    #[tokio::test]
1459    async fn integration_sni_overrides_cache_for_over_allow() {
1460        let shared = shared_with("pypi.org", SHARED_FASTLY_IP);
1461        let policy = NetworkPolicy {
1462            default_egress: Action::Deny,
1463            default_ingress: Action::Allow,
1464            rules: vec![allow_https("pypi.org")],
1465        };
1466        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1467
1468        let (tx, mut rx) = mpsc::channel(4);
1469        tx.send(Bytes::from(synthetic_client_hello("evil.com")))
1470            .await
1471            .unwrap();
1472        drop(tx);
1473
1474        let (initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1475        assert_eq!(sni.as_deref(), Some("evil.com"));
1476        assert!(!initial_buf.is_empty());
1477
1478        let source = sni
1479            .as_deref()
1480            .map(HostnameSource::Sni)
1481            .unwrap_or(HostnameSource::CacheOnly);
1482        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1483        assert_eq!(
1484            eval,
1485            EgressEvaluation::Deny,
1486            "SNI=evil.com must not piggy-back on the cached pypi.org match",
1487        );
1488    }
1489
1490    /// Over-block case: cache says IP X is `ads.example.com` (denied);
1491    /// SNI is `api.example.com`. SNI must override the cache and allow.
1492    #[tokio::test]
1493    async fn integration_sni_overrides_cache_for_over_block() {
1494        let shared = shared_with("ads.example.com", SHARED_FASTLY_IP);
1495        let policy = NetworkPolicy {
1496            default_egress: Action::Allow,
1497            default_ingress: Action::Allow,
1498            rules: vec![Rule::deny_egress(Destination::Domain(
1499                "ads.example.com".parse().unwrap(),
1500            ))],
1501        };
1502        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1503
1504        let (tx, mut rx) = mpsc::channel(4);
1505        tx.send(Bytes::from(synthetic_client_hello("api.example.com")))
1506            .await
1507            .unwrap();
1508        drop(tx);
1509
1510        let (_initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1511        assert_eq!(sni.as_deref(), Some("api.example.com"));
1512
1513        let source = sni
1514            .as_deref()
1515            .map(HostnameSource::Sni)
1516            .unwrap_or(HostnameSource::CacheOnly);
1517        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1518        assert_eq!(
1519            eval,
1520            EgressEvaluation::Allow,
1521            "SNI=api.example.com must not be caught by the deny on ads.example.com",
1522        );
1523    }
1524
1525    /// Non-TLS first-flight falls back to `CacheOnly`; the cache
1526    /// match decides.
1527    #[tokio::test]
1528    async fn integration_non_tls_falls_back_to_cache() {
1529        let shared = shared_with("pypi.org", SHARED_FASTLY_IP);
1530        let policy = NetworkPolicy {
1531            default_egress: Action::Deny,
1532            default_ingress: Action::Allow,
1533            rules: vec![allow_https("pypi.org")],
1534        };
1535        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1536
1537        let (tx, mut rx) = mpsc::channel(4);
1538        // Plain HTTP request; not a TLS record.
1539        tx.send(Bytes::from_static(
1540            b"GET / HTTP/1.1\r\nHost: pypi.org\r\n\r\n",
1541        ))
1542        .await
1543        .unwrap();
1544        drop(tx);
1545
1546        let (initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1547        assert_eq!(sni, None, "non-TLS data → no SNI");
1548        assert!(
1549            !initial_buf.is_empty(),
1550            "buffered bytes must survive for replay"
1551        );
1552
1553        let source = sni
1554            .as_deref()
1555            .map(HostnameSource::Sni)
1556            .unwrap_or(HostnameSource::CacheOnly);
1557        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1558        assert_eq!(
1559            eval,
1560            EgressEvaluation::Allow,
1561            "cache-only fallback must still allow the cached hostname's IP",
1562        );
1563    }
1564
1565    /// SNI matches a `DomainSuffix` rule with a cache binding for the
1566    /// claimed name. Genuine pre-resolved traffic passes.
1567    #[tokio::test]
1568    async fn integration_sni_matches_domain_suffix_with_cache_binding() {
1569        let shared = shared_with("files.pythonhosted.org", SHARED_FASTLY_IP);
1570        let policy = NetworkPolicy {
1571            default_egress: Action::Deny,
1572            default_ingress: Action::Allow,
1573            rules: vec![Rule {
1574                direction: crate::policy::Direction::Egress,
1575                destination: Destination::DomainSuffix(".pythonhosted.org".parse().unwrap()),
1576                protocols: vec![Protocol::Tcp],
1577                ports: vec![PortRange::single(443)],
1578                action: Action::Allow,
1579            }],
1580        };
1581        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1582
1583        let (tx, mut rx) = mpsc::channel(4);
1584        tx.send(Bytes::from(synthetic_client_hello(
1585            "files.pythonhosted.org",
1586        )))
1587        .await
1588        .unwrap();
1589        drop(tx);
1590
1591        let (_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1592        let source = sni
1593            .as_deref()
1594            .map(HostnameSource::Sni)
1595            .unwrap_or(HostnameSource::CacheOnly);
1596        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1597        assert_eq!(eval, EgressEvaluation::Allow);
1598    }
1599
1600    /// Spoofed SNI on an IP with no cache binding for any matching
1601    /// name: byte-equality with the suffix passes, but no DNS lookup
1602    /// ever tied a `*.pythonhosted.org` name to the destination, so
1603    /// the AND-check fails and the connection is denied.
1604    #[tokio::test]
1605    async fn integration_sni_denies_domain_suffix_without_cache_binding() {
1606        let shared = SharedState::new(4); // empty cache
1607        let policy = NetworkPolicy {
1608            default_egress: Action::Deny,
1609            default_ingress: Action::Allow,
1610            rules: vec![Rule {
1611                direction: crate::policy::Direction::Egress,
1612                destination: Destination::DomainSuffix(".pythonhosted.org".parse().unwrap()),
1613                protocols: vec![Protocol::Tcp],
1614                ports: vec![PortRange::single(443)],
1615                action: Action::Allow,
1616            }],
1617        };
1618        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1619
1620        let (tx, mut rx) = mpsc::channel(4);
1621        tx.send(Bytes::from(synthetic_client_hello(
1622            "files.pythonhosted.org",
1623        )))
1624        .await
1625        .unwrap();
1626        drop(tx);
1627
1628        let (_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1629        let source = sni
1630            .as_deref()
1631            .map(HostnameSource::Sni)
1632            .unwrap_or(HostnameSource::CacheOnly);
1633        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1634        assert_eq!(eval, EgressEvaluation::Deny);
1635    }
1636
1637    // ── extract_http_host ──────────────────────────────────────────────────────
1638
1639    #[test]
1640    fn extract_http_host_basic() {
1641        let buf = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
1642        assert_eq!(extract_http_host(buf), Some("example.com".into()));
1643    }
1644
1645    #[test]
1646    fn extract_http_host_strips_port() {
1647        let buf = b"POST /api HTTP/1.1\r\nHost: api.company.com:8080\r\n\r\n";
1648        assert_eq!(extract_http_host(buf), Some("api.company.com".into()));
1649    }
1650
1651    #[test]
1652    fn extract_http_host_case_insensitive_lowercased() {
1653        let buf = b"GET / HTTP/1.1\r\nhost: Example.COM\r\n\r\n";
1654        assert_eq!(extract_http_host(buf), Some("example.com".into()));
1655    }
1656
1657    #[test]
1658    fn extract_http_host_no_host_header() {
1659        let buf = b"GET / HTTP/1.1\r\nX-Other: foo\r\n\r\n";
1660        assert_eq!(extract_http_host(buf), None);
1661    }
1662
1663    #[test]
1664    fn extract_http_host_incomplete_headers() {
1665        let buf = b"GET / HTTP/1.1\r\nHost: x";
1666        assert_eq!(extract_http_host(buf), None);
1667    }
1668
1669    #[test]
1670    fn extract_http_host_tls_first_byte() {
1671        let buf = [0x16u8, 0x03, 0x01, 0x00, 0x01];
1672        assert_eq!(extract_http_host(&buf), None);
1673    }
1674
1675    #[test]
1676    fn extract_http_host_with_many_headers() {
1677        // Far more headers than a small fixed parse array would hold: the Host
1678        // must still be found rather than the request looking hostless.
1679        let mut req = Vec::from(&b"GET / HTTP/1.1\r\n"[..]);
1680        for i in 0..100 {
1681            req.extend_from_slice(format!("X-Pad-{i}: v\r\n").as_bytes());
1682        }
1683        req.extend_from_slice(b"Host: example.com\r\n\r\n");
1684        assert_eq!(extract_http_host(&req), Some("example.com".into()));
1685    }
1686
1687    // ── plain-HTTP secret substitution ────────────────────────────────────────
1688
1689    use std::sync::Arc;
1690    use tokio::io::AsyncReadExt;
1691    use tokio::net::TcpListener;
1692    use tokio::task::JoinHandle;
1693
1694    use crate::secrets::config::{HostPattern, SecretEntry, SecretInjection, SecretsConfig};
1695
1696    fn make_plain_http_secret(placeholder: &str, value: &str, require_tls: bool) -> SecretsConfig {
1697        SecretsConfig {
1698            secrets: vec![SecretEntry {
1699                env_var: "API_KEY".into(),
1700                value: zeroize::Zeroizing::new(value.into()),
1701                source: None,
1702                placeholder: placeholder.into(),
1703                allowed_hosts: vec![HostPattern::Any],
1704                injection: SecretInjection {
1705                    headers: true,
1706                    basic_auth: false,
1707                    query_params: false,
1708                    body: false,
1709                },
1710                on_violation: None,
1711                require_tls_identity: require_tls,
1712            }],
1713            ..Default::default()
1714        }
1715    }
1716
1717    fn make_host_bound_secret(placeholder: &str, value: &str, host: &str) -> SecretsConfig {
1718        SecretsConfig {
1719            secrets: vec![SecretEntry {
1720                env_var: "API_KEY".into(),
1721                value: zeroize::Zeroizing::new(value.into()),
1722                source: None,
1723                placeholder: placeholder.into(),
1724                allowed_hosts: vec![HostPattern::Exact(host.into())],
1725                injection: SecretInjection::default(),
1726                on_violation: None,
1727                require_tls_identity: true,
1728            }],
1729            ..Default::default()
1730        }
1731    }
1732
1733    #[test]
1734    fn sanitize_connect_headers_blocks_placeholder_metadata_header_by_default() {
1735        let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1736        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";
1737
1738        assert_eq!(
1739            sanitize_connect_headers(headers, &secrets),
1740            Err(ViolationAction::BlockAndLog)
1741        );
1742    }
1743
1744    #[test]
1745    fn sanitize_connect_headers_respects_block_and_terminate() {
1746        let mut secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1747        secrets.on_violation = ViolationAction::BlockAndTerminate;
1748        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";
1749
1750        assert_eq!(
1751            sanitize_connect_headers(headers, &secrets),
1752            Err(ViolationAction::BlockAndTerminate)
1753        );
1754    }
1755
1756    #[test]
1757    fn sanitize_connect_headers_respects_explicit_passthrough() {
1758        let mut secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1759        secrets.on_violation = ViolationAction::Passthrough(vec![HostPattern::Any]);
1760        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";
1761
1762        let sanitized = sanitize_connect_headers(headers, &secrets).unwrap();
1763
1764        assert_eq!(sanitized.as_ref(), headers);
1765        assert!(
1766            !String::from_utf8_lossy(sanitized.as_ref()).contains("real-secret-value"),
1767            "passthrough must never substitute real secrets into CONNECT metadata"
1768        );
1769    }
1770
1771    #[test]
1772    fn sanitize_connect_headers_keeps_safe_metadata_headers() {
1773        let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1774        let headers =
1775            b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nUser-Agent: curl\r\n\r\n";
1776
1777        let sanitized = sanitize_connect_headers(headers, &secrets).unwrap();
1778
1779        assert_eq!(sanitized.as_ref(), headers);
1780    }
1781
1782    #[test]
1783    fn sanitize_connect_headers_blocks_placeholder_in_request_line() {
1784        let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1785        let headers = b"CONNECT $MSB_KEY:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n";
1786
1787        assert_eq!(
1788            sanitize_connect_headers(headers, &secrets),
1789            Err(ViolationAction::BlockAndLog)
1790        );
1791    }
1792
1793    async fn spawn_sink() -> (SocketAddr, JoinHandle<Vec<u8>>) {
1794        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1795        let addr = listener.local_addr().unwrap();
1796        let handle = tokio::spawn(async move {
1797            let (mut stream, _) = listener.accept().await.unwrap();
1798            let mut received = Vec::new();
1799            let mut buf = vec![0u8; 4096];
1800            loop {
1801                match stream.read(&mut buf).await {
1802                    Ok(0) | Err(_) => break,
1803                    Ok(n) => received.extend_from_slice(&buf[..n]),
1804                }
1805            }
1806            received
1807        });
1808        (addr, handle)
1809    }
1810
1811    async fn relay_through_proxy(
1812        request: Vec<u8>,
1813        secrets: SecretsConfig,
1814        handle: JoinHandle<Vec<u8>>,
1815        server_addr: SocketAddr,
1816    ) -> Vec<u8> {
1817        let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1818        let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1819        let shared = SharedState::new(4);
1820        let policy = Arc::new(NetworkPolicy::default());
1821        let secrets = Arc::new(secrets);
1822        let proxy_connect = Arc::new(ProxyConnectState::new());
1823
1824        from_tx.send(Bytes::from(request)).await.unwrap();
1825        drop(from_tx);
1826
1827        TcpProxy::new(
1828            server_addr,
1829            UpstreamTcpTarget::direct(server_addr),
1830            from_rx,
1831            to_tx,
1832            Arc::new(shared),
1833            policy,
1834            secrets,
1835            None,
1836            proxy_connect,
1837            None,
1838        )
1839        .try_run()
1840        .await
1841        .unwrap();
1842
1843        handle.await.unwrap()
1844    }
1845
1846    #[tokio::test]
1847    async fn plain_http_substitutes_placeholder_when_host_arrives_in_second_segment() {
1848        // Host header split across TCP segments — classify_first_flight must keep
1849        // reading until \r\n\r\n before extract_http_host is called.
1850        let (addr, sink) = spawn_sink().await;
1851        let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", false);
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_static(b"GET /api HTTP/1.1\r\n"))
1859            .await
1860            .unwrap();
1861        from_tx
1862            .send(Bytes::from_static(
1863                b"Host: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n",
1864            ))
1865            .await
1866            .unwrap();
1867        drop(from_tx);
1868
1869        TcpProxy::new(
1870            addr,
1871            UpstreamTcpTarget::direct(addr),
1872            from_rx,
1873            to_tx,
1874            Arc::new(SharedState::new(4)),
1875            Arc::new(NetworkPolicy::default()),
1876            Arc::new(secrets),
1877            None,
1878            proxy_connect,
1879            None,
1880        )
1881        .try_run()
1882        .await
1883        .unwrap();
1884
1885        let wire = String::from_utf8(sink.await.unwrap()).unwrap();
1886        assert!(wire.contains("real-secret-value"), "got: {wire:?}");
1887        assert!(!wire.contains("$MSB_KEY"), "got: {wire:?}");
1888    }
1889
1890    #[tokio::test]
1891    async fn plain_http_forwards_placeholder_to_allowed_host_with_split_headers() {
1892        // A default (require_tls_identity = true) host-bound secret is never
1893        // substituted over plain HTTP, but a request to its allowed host must
1894        // have the placeholder forwarded unchanged — not blocked as a violation
1895        // — even when the Host arrives in a later segment than the request line.
1896        let (addr, sink) = spawn_sink().await;
1897
1898        let shared = SharedState::new(4);
1899        shared.cache_resolved_hostname(
1900            "example.com",
1901            ResolvedHostnameFamily::Ipv4,
1902            ["127.0.0.1".parse::<IpAddr>().unwrap()],
1903            StdDuration::from_secs(60),
1904        );
1905
1906        let secrets = SecretsConfig {
1907            secrets: vec![SecretEntry {
1908                env_var: "API_KEY".into(),
1909                value: zeroize::Zeroizing::new("real-secret-value".into()),
1910                source: None,
1911                placeholder: "$MSB_KEY".into(),
1912                allowed_hosts: vec![HostPattern::Exact("example.com".into())],
1913                injection: SecretInjection {
1914                    headers: true,
1915                    basic_auth: false,
1916                    query_params: false,
1917                    body: false,
1918                },
1919                on_violation: None,
1920                require_tls_identity: true,
1921            }],
1922            ..Default::default()
1923        };
1924
1925        let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1926        let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1927        let proxy_connect = Arc::new(ProxyConnectState::new());
1928
1929        from_tx
1930            .send(Bytes::from_static(b"GET /api HTTP/1.1\r\n"))
1931            .await
1932            .unwrap();
1933        from_tx
1934            .send(Bytes::from_static(
1935                b"Host: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n",
1936            ))
1937            .await
1938            .unwrap();
1939        drop(from_tx);
1940
1941        TcpProxy::new(
1942            addr,
1943            UpstreamTcpTarget::direct(addr),
1944            from_rx,
1945            to_tx,
1946            Arc::new(shared),
1947            Arc::new(NetworkPolicy::default()),
1948            Arc::new(secrets),
1949            None,
1950            proxy_connect,
1951            None,
1952        )
1953        .try_run()
1954        .await
1955        .unwrap();
1956
1957        let wire = String::from_utf8(sink.await.unwrap()).unwrap();
1958        assert!(
1959            wire.contains("Host: example.com"),
1960            "request must reach the allowed host, got: {wire:?}"
1961        );
1962        assert!(
1963            wire.contains("$MSB_KEY"),
1964            "placeholder must be forwarded unchanged for a require_tls_identity secret, got: {wire:?}"
1965        );
1966        assert!(
1967            !wire.contains("real-secret-value"),
1968            "secret must never be substituted over plain HTTP, got: {wire:?}"
1969        );
1970    }
1971
1972    #[tokio::test]
1973    async fn plain_http_substitutes_placeholder_in_first_flight() {
1974        let (addr, sink) = spawn_sink().await;
1975
1976        let request =
1977            b"GET /api HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n"
1978                .to_vec();
1979        let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", false);
1980
1981        let wire =
1982            String::from_utf8(relay_through_proxy(request, secrets, sink, addr).await).unwrap();
1983        assert!(
1984            wire.contains("real-secret-value"),
1985            "real value must reach server, got: {wire:?}"
1986        );
1987        assert!(
1988            !wire.contains("$MSB_KEY"),
1989            "placeholder must not reach server, got: {wire:?}"
1990        );
1991    }
1992
1993    #[tokio::test]
1994    async fn plain_http_no_substitution_when_require_tls_identity_true() {
1995        let (addr, sink) = spawn_sink().await;
1996
1997        let request =
1998            b"GET /api HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n"
1999                .to_vec();
2000        let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", true);
2001
2002        let wire =
2003            String::from_utf8_lossy(&relay_through_proxy(request, secrets, sink, addr).await)
2004                .into_owned();
2005        assert!(
2006            wire.contains("$MSB_KEY"),
2007            "placeholder must be forwarded unchanged when require_tls_identity=true, got: {wire:?}"
2008        );
2009        assert!(
2010            !wire.contains("real-secret-value"),
2011            "real value must not leak when require_tls_identity=true, got: {wire:?}"
2012        );
2013    }
2014
2015    #[tokio::test]
2016    async fn plain_http_large_body_forwarded_verbatim_in_relay_loop() {
2017        // Body arrives in a separate segment after headers — flows through the relay
2018        // loop, not the peek path. Ensures no bytes are dropped and header substitution
2019        // still happens.
2020        let (addr, sink) = spawn_sink().await;
2021        let secrets = make_plain_http_secret("$MSB_KEY", "real-value", false);
2022
2023        let body = "x".repeat(32_000);
2024        let header = format!(
2025            "POST /upload HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\nContent-Length: {}\r\n\r\n",
2026            body.len()
2027        );
2028
2029        let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
2030        let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
2031        let proxy_connect = Arc::new(ProxyConnectState::new());
2032
2033        from_tx
2034            .send(Bytes::from(header.into_bytes()))
2035            .await
2036            .unwrap();
2037        from_tx
2038            .send(Bytes::from(body.clone().into_bytes()))
2039            .await
2040            .unwrap();
2041        drop(from_tx);
2042
2043        TcpProxy::new(
2044            addr,
2045            UpstreamTcpTarget::direct(addr),
2046            from_rx,
2047            to_tx,
2048            Arc::new(SharedState::new(4)),
2049            Arc::new(NetworkPolicy::default()),
2050            Arc::new(secrets),
2051            None,
2052            proxy_connect,
2053            None,
2054        )
2055        .try_run()
2056        .await
2057        .unwrap();
2058
2059        let wire = String::from_utf8_lossy(&sink.await.unwrap()).into_owned();
2060        assert!(wire.contains(&body), "got {} bytes", wire.len());
2061        assert!(!wire.contains("$MSB_KEY"), "got: {wire:?}");
2062    }
2063}