Skip to main content

microsandbox_network/
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 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
30//--------------------------------------------------------------------------------------------------
31// Constants
32//--------------------------------------------------------------------------------------------------
33
34/// Buffer size for reading from the real server.
35const SERVER_READ_BUF_SIZE: usize = 16384;
36
37/// Max bytes buffered while reading the proxy's CONNECT response headers.
38const CONNECT_RESP_LIMIT: usize = 8192;
39
40/// Max bytes to buffer while peeking for the ClientHello's SNI.
41const PEEK_BUF_SIZE: usize = 16384;
42
43/// Upper bound on time spent buffering the first flight before
44/// falling back to a cache-only egress decision.
45const PEEK_BUDGET: Duration = Duration::from_secs(5);
46
47//--------------------------------------------------------------------------------------------------
48// Types
49//--------------------------------------------------------------------------------------------------
50
51#[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
65//--------------------------------------------------------------------------------------------------
66// Methods
67//--------------------------------------------------------------------------------------------------
68
69impl 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
114//--------------------------------------------------------------------------------------------------
115// Functions
116//--------------------------------------------------------------------------------------------------
117
118/// Dial `dst` and update proxy state; wakes the poll thread on failure.
119pub(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/// Spawn a TCP proxy task for a newly established connection.
138///
139/// `guest_dst` is what the guest dialed — the address policy rules
140/// match against. `connect_dst` is the host-side address tokio actually
141/// dials; for host-alias connections it's loopback (gateway rewritten).
142/// For everything else the two are identical.
143///
144/// `proxy_connect` is updated before the task exits so the connection
145/// tracker can decide between FIN (clean close) and RST (upstream
146/// connect failure).
147#[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/// Core TCP proxy: peek for SNI, evaluate egress policy, then either
180/// connect and relay or drop the channels.
181#[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    // Pre-connect peek is only for domain policy: the hostname has to be known
194    // before we dial upstream so a Deny never opens a connection. Secrets do
195    // *not* gate the connect, so they no longer force a peek here — that work is
196    // deferred to `classify_first_flight` after the socket is open, where it can
197    // run without stalling server-first protocols (see below).
198    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    // Re-evaluate egress against the *guest* dst — the address the
205    // guest dialed, not the post-rewrite host-side address. SNI
206    // refines over-allow when the cache matched a shared CDN IP;
207    // CacheOnly is the non-TLS fallback path so Domain rules still
208    // gate plain HTTP / SSH / etc.
209    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    // Peek for HTTP CONNECT before dialing upstream; hand off if detected.
237    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    // Connect upstream *before* finishing the secrets-side classification. A
260    // server-first protocol (SSH, SMTP, a database) sends nothing until it has
261    // seen the server's banner; with the socket already open we can relay that
262    // banner while we wait, instead of burning the peek budget pre-connect.
263    let stream = connect_upstream(connect_dst, &proxy_connect, &shared).await?;
264    let (mut server_rx, mut server_tx) = stream.into_split();
265
266    // Finish classifying the first flight (TLS vs plain HTTP) and, for
267    // plain-HTTP candidates, gather a full header block — without blocking the
268    // server→guest direction. When domain rules already peeked, `initial_buf`
269    // is reused and this is cheap; with no secrets it is skipped entirely
270    // (`is_tls` only matters for deciding whether to build the handler).
271    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        // The pre-connect CONNECT peek can miss a client whose first bytes arrive
292        // after we dial upstream. Once classify_first_flight has captured that
293        // request, rejoin the already-open proxy socket and use the CONNECT path
294        // so intercepted tunnels still get TLS substitution and policy checks.
295        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    // Replay the buffered first flight — run through secrets handler first.
324    if !initial_buf.is_empty() {
325        let out: Cow<[u8]> = match secrets_handler.as_mut() {
326            Some(h) => match h.substitute(&initial_buf) {
327                // Borrow the input when nothing was substituted; only a chunk
328                // that actually carries a placeholder is reallocated.
329                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    // Bidirectional relay using tokio::select!.
355    //
356    // guest → server: receive from channel, write to server socket.
357    // server → guest: read from server socket, send via channel + wake poll.
358    let mut guest_eof = false;
359    loop {
360        tokio::select! {
361            // Guest → server: substitute placeholders before forwarding.
362            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                            // The first guest bytes can arrive after both peek
369                            // windows have completed. Nothing has been written
370                            // to the proxy socket yet, so this is still a valid
371                            // point to switch into CONNECT tunnel handling.
372                            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                        // No handler (no secrets / TLS) is the common path: forward
390                        // the chunk borrowed, with no per-chunk allocation or copy.
391                        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                    // Channel closed — the guest half-closed (FIN) or the
416                    // connection was torn down. Propagate the half-close:
417                    // stop sending upstream but keep relaying server →
418                    // guest until the server closes.
419                    None => {
420                        guest_eof = true;
421                        if server_tx.shutdown().await.is_err() {
422                            break;
423                        }
424                    }
425                }
426            }
427
428            // Server → guest: no substitution — server never sends placeholders.
429            result = server_rx.read(&mut server_buf) => {
430                match result {
431                    Ok(0) => break, // Server closed connection.
432                    Ok(n) => {
433                        // A server-first byte means this is not an HTTP CONNECT
434                        // tunnel to a proxy. Keep relaying normally afterward.
435                        late_connect_state = None;
436                        let data = Bytes::copy_from_slice(&server_buf[..n]);
437                        if to_smoltcp.send(data).await.is_err() {
438                            // Channel closed — poll loop dropped the receiver.
439                            break;
440                        }
441                        // Wake the poll thread so it writes data to the
442                        // smoltcp socket.
443                        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/// Forward an HTTP CONNECT tunnel: dial the proxy, splice the handshake,
458/// then hand the established stream to `tls_proxy_task` for TLS MITM.
459///
460/// `guest_dst` is what the guest dialed; `proxy_dst` is the rewritten
461/// loopback address the gateway actually connects to.
462#[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    // Dial the proxy and forward the CONNECT request so it opens the tunnel.
493    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
589/// Relay an established TCP stream without inspecting or substituting bytes.
590async 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                    // Guest half-closed (FIN): stop sending upstream but
609                    // keep relaying server → guest until the server closes.
610                    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
732/// Returns the byte offset just past the `\r\n\r\n` header terminator, or `None`.
733fn 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
862/// Extract the `Host:` header value from an already-buffered HTTP header block.
863///
864/// Returns `None` if:
865/// - The first byte is `0x16` (TLS — not HTTP)
866/// - The buffer does not yet contain `\r\n\r\n` (headers incomplete)
867/// - No `Host:` header is present
868///
869/// Strips port suffix, lowercases, and trims whitespace. Result is
870/// ready for byte-equal matching against `SecretEntry::allowed_hosts`.
871fn extract_http_host(buf: &[u8]) -> Option<String> {
872    if buf.first() == Some(&0x16) {
873        return None;
874    }
875    // Size the header pool to the buffer rather than a fixed array: a header
876    // line is at least four bytes (`a:\r\n`), so `len / 4` always covers the
877    // real header count, and `httparse` never reports `TooManyHeaders` (which
878    // would make a request with many headers look hostless). The first flight
879    // is capped at PEEK_BUF_SIZE, so this stays bounded.
880    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            // Strip port suffix.
890            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/// Finish classifying the guest's first flight after the upstream socket is
899/// open, returning the (possibly extended) first-flight buffer and whether it
900/// is a TLS record.
901///
902/// `buf` carries whatever a pre-connect domain-rule peek already captured; when
903/// it is non-empty the TLS/plain decision is already settled and only header
904/// top-up runs. `want_headers` is set when at least one secret can be
905/// substituted over plain HTTP (`SecretsConfig::has_plain_http_candidates`); it
906/// makes the peek keep reading a non-TLS flight until `\r\n\r\n` so
907/// [`extract_http_host`] sees a complete header block.
908///
909/// Crucially, this relays server→guest while it waits. Server-first protocols
910/// (SSH, SMTP, databases) send nothing until they have seen the server's
911/// banner; draining the server side here lets the banner reach the guest
912/// immediately, so the guest's eventual first flight — not a 5s timeout — is
913/// what ends the peek.
914#[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        // Stop as soon as the protocol class is known and — for plain-HTTP
931        // candidates — a full header block has arrived. Bail the moment a
932        // non-TLS flight stops looking like an HTTP request so non-HTTP
933        // protocols (SSH, Postgres) aren't withheld from upstream for the
934        // whole budget while we wait for a `\r\n\r\n` that never comes.
935        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 → buffer (not forwarded here; the caller replays it once the
956            // handler is built, so substitution applies to the first flight too).
957            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 → guest: relay immediately so a server-first banner is never
965            // held hostage by the peek.
966            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
985/// Buffer the first flight until SNI can be extracted, or until one
986/// of the bail-out conditions hits (channel close, buffer cap,
987/// timeout). Never errors; non-TLS / slow / malformed input all
988/// fall through to `None`.
989///
990/// On hit, the SNI is canonicalized (lowercase + trim trailing dot)
991/// for byte-equal matching against rule destinations. The returned
992/// buffer must be replayed verbatim to upstream before the caller
993/// starts its relay loop.
994async 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                        // First byte of a TLS record is the ContentType;
1012                        // 0x16 is handshake. Anything else can't be a
1013                        // ClientHello, so don't burn the full budget on
1014                        // plain HTTP / SSH / etc.
1015                        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//--------------------------------------------------------------------------------------------------
1036// Tests
1037//--------------------------------------------------------------------------------------------------
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    /// Synthetic TLS ClientHello carrying SNI `example.com`. Bytes
1044    /// borrowed from `tls::sni` test fixtures so the parser sees a
1045    /// well-formed record.
1046    fn synthetic_client_hello(sni: &str) -> Vec<u8> {
1047        // Minimal but valid TLS 1.2 ClientHello with one SNI entry.
1048        // Layout: record header (5) + handshake header (4) + body.
1049        let host_bytes = sni.as_bytes();
1050        let host_len = host_bytes.len() as u16;
1051        let server_name_list_len = 3 + host_len; // type(1) + len(2) + host
1052        let extension_data_len = 2 + server_name_list_len; // list-len(2) + list
1053        let extensions_total = 4 + extension_data_len; // type(2) + len(2) + data
1054
1055        let mut body = Vec::new();
1056        // Client version
1057        body.extend_from_slice(&[0x03, 0x03]);
1058        // Random (32 bytes)
1059        body.extend_from_slice(&[0u8; 32]);
1060        // Session id length + (empty)
1061        body.push(0);
1062        // Cipher suites length + one cipher
1063        body.extend_from_slice(&[0x00, 0x02, 0x00, 0x2f]);
1064        // Compression methods length + null
1065        body.extend_from_slice(&[0x01, 0x00]);
1066        // Extensions length
1067        body.extend_from_slice(&extensions_total.to_be_bytes());
1068        // SNI extension: type 0x0000
1069        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); // host_name type
1073        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); // ClientHello
1079        hs.extend_from_slice(&handshake_len.to_be_bytes()[1..]); // 24-bit length
1080        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]); // Handshake, TLS 1.0
1085        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); // close so peek returns even if SNI didn't satisfy
1166
1167        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        // Plaintext HTTP request; not a TLS record so extract_sni returns None.
1185        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        // Hold the sender open but send nothing — peek must time out.
1203        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        // First byte 0x16 keeps the peek collecting past the early
1213        // non-TLS bail. Padding bytes are zero so the SNI parser never
1214        // matches and the loop drives to the size cap.
1215        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        // Plain HTTP request: first byte 'G' (0x47) — clearly not TLS.
1235        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        // 5-second nominal budget; assert we returned in well under
1241        // that — the early-bail must not wait for the full window.
1242        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    //----------------------------------------------------------------------------------------------
1254    // peek_for_sni × evaluate_egress_with_source — combined integration tests
1255    //----------------------------------------------------------------------------------------------
1256
1257    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    /// Over-allow case: cache says IP X is `pypi.org` (allowed); SNI
1287    /// is `evil.com`. SNI must override the cache and deny.
1288    #[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    /// Over-block case: cache says IP X is `ads.example.com` (denied);
1321    /// SNI is `api.example.com`. SNI must override the cache and allow.
1322    #[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    /// Non-TLS first-flight falls back to `CacheOnly`; the cache
1356    /// match decides.
1357    #[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        // Plain HTTP request; not a TLS record.
1369        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    /// SNI matches a `DomainSuffix` rule with a cache binding for the
1396    /// claimed name. Genuine pre-resolved traffic passes.
1397    #[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    /// Spoofed SNI on an IP with no cache binding for any matching
1431    /// name: byte-equality with the suffix passes, but no DNS lookup
1432    /// ever tied a `*.pythonhosted.org` name to the destination, so
1433    /// the AND-check fails and the connection is denied.
1434    #[tokio::test]
1435    async fn integration_sni_denies_domain_suffix_without_cache_binding() {
1436        let shared = SharedState::new(4); // empty cache
1437        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    // ── extract_http_host ──────────────────────────────────────────────────────
1468
1469    #[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        // Far more headers than a small fixed parse array would hold: the Host
1508        // must still be found rather than the request looking hostless.
1509        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    // ── plain-HTTP secret substitution ────────────────────────────────────────
1518
1519    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        // Host header split across TCP segments — classify_first_flight must keep
1677        // reading until \r\n\r\n before extract_http_host is called.
1678        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        // A default (require_tls_identity = true) host-bound secret is never
1719        // substituted over plain HTTP, but a request to its allowed host must
1720        // have the placeholder forwarded unchanged — not blocked as a violation
1721        // — even when the Host arrives in a later segment than the request line.
1722        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        // Body arrives in a separate segment after headers — flows through the relay
1842        // loop, not the peek path. Ensures no bytes are dropped and header substitution
1843        // still happens.
1844        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}