OXYGENHUB

Coverage / crates/flow/src/egress.rs 79.2%

171 of 216 lines run, 15 of 33 functions · run #189 on main · run missed

1
//! Allow-listed egress for a microVM job (oxygenflow#55). The VM has no
2
//! network device: `flow-init` serves a proxy on the guest's 127.0.0.1
3
//! and relays each connection over vsock to this end, a Unix socket that
4
//! Firecracker connects for the guest. Here each `CONNECT host:443` is
5
//! checked against the sandbox's allow-list, the host is resolved (the
6
//! guest never resolves), any address that is not public is refused, the
7
//! decision goes into the job log, and the bytes are relayed untouched (no
8
//! TLS is opened here).
9
10
#[cfg(unix)]
11
use std::io::{Read, Write};
12
use std::net::IpAddr;
13
#[cfg(unix)]
14
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
15
#[cfg(unix)]
16
use std::os::unix::net::{UnixListener, UnixStream};
17
use std::path::Path;
18
#[cfg(unix)]
19
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20
use std::sync::mpsc;
21
#[cfg(unix)]
22
use std::sync::Arc;
23
#[cfg(unix)]
24
use std::time::Duration;
25
26
/// The vsock port the guest's relay connects to on the host (CID 2).
27
pub const VSOCK_PORT: u32 = 3128;
28
/// Where the job's tools find the proxy inside the guest.
29
pub const GUEST_PROXY: &str = "http://127.0.0.1:3128";
30
/// The only port relayed: TLS.
31
const PORT: u16 = 443;
32
#[cfg(unix)]
33
/// Connections at once, per job.
34
const MAX_OPEN: usize = 64;
35
#[cfg(unix)]
36
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
37
#[cfg(unix)]
38
/// The longest request head read.
39
const MAX_HEAD: usize = 8 * 1024;
40
41
/// What a CONNECT asked for, or why it was refused.
42
#[derive(Debug, PartialEq, Eq)]
43
pub enum Ask {
44
    Connect { host: String, port: u16 },
45
    Refused(String),
46
}
47
48
/// Read a request head: `CONNECT host:port HTTP/1.x`, and only that.
495×
pub fn parse_head(head: &str) -> Ask {
505×
    let line = head.lines().next().unwrap_or("");
515×
    let mut parts = line.split_whitespace();
525×
    let (method, target, version) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
535×
    if method != "CONNECT" {
542×
        return Ask::Refused(format!("only https is relayed (CONNECT), not {method:?}"));
553×
    }
563×
    if !version.starts_with("HTTP/1.") {
570×
        return Ask::Refused("not an HTTP/1 request".into());
583×
    }
593×
    let Some((host, port)) = target.rsplit_once(':') else { return Ask::Refused(format!("CONNECT {target:?} names no port")) };
602×
    let Ok(port) = port.parse::<u16>() else { return Ask::Refused(format!("CONNECT {target:?} has no valid port")) };
612×
    let host = host.trim_start_matches('[').trim_end_matches(']').trim_end_matches('.').to_ascii_lowercase();
622×
    if host.is_empty() {
630×
        return Ask::Refused("CONNECT names no host".into());
642×
    }
652×
    Ask::Connect { host, port }
665×
}
67
68
/// Whether the job may go to `host:port`: the port is 443, the host is a
69
/// name (an address is refused: the allow-list names hosts) the list allows.
706×
pub fn decide(allow: &[String], host: &str, port: u16) -> Result<(), String> {
716×
    if port != PORT {
721×
        return Err(format!("only port {PORT} is relayed"));
735×
    }
745×
    if host.parse::<IpAddr>().is_ok() {
751×
        return Err("an address, not a name: the allow-list names hosts".into());
764×
    }
774×
    if !flow_core::egress_allows(allow, host) {
782×
        return Err("not in the sandbox's egress allow-list".into());
792×
    }
802×
    Ok(())
816×
}
82
83
/// Whether an address is on the public internet: not loopback, private,
84
/// shared (CGNAT), link-local (cloud metadata among them), multicast,
85
/// unspecified or documentation, nor an IPv6 local range or a mapped form.
8614×
pub fn public(ip: IpAddr) -> bool {
8714×
    match ip {
889×
        IpAddr::V4(v4) => {
899×
            let o = v4.octets();
909×
            !(v4.is_loopback()
918×
                || v4.is_private()
924×
                || v4.is_link_local()
933×
                || v4.is_multicast()
943×
                || v4.is_broadcast()
953×
                || v4.is_unspecified()
962×
                || v4.is_documentation()
972×
                || o[0] == 0
982×
                || (o[0] == 100 && (o[1] & 0xc0) == 64)
991×
                || (o[0] == 192 && o[1] == 0 && o[2] == 0)
1001×
                || o[0] >= 240)
101
        }
1025×
        IpAddr::V6(v6) => {
1035×
            if let Some(v4) = v6.to_ipv4_mapped() {
1041×
                return public(IpAddr::V4(v4));
1054×
            }
1064×
            let s = v6.segments();
1074×
            !(v6.is_loopback()
1083×
                || v6.is_unspecified()
1093×
                || v6.is_multicast()
1103×
                || (s[0] & 0xfe00) == 0xfc00
1112×
                || (s[0] & 0xffc0) == 0xfe80
1121×
                || (s[0] == 0x64 && s[1] == 0xff9b)
1131×
                || (s[0] == 0x2001 && s[1] == 0x0db8))
114
        }
115
    }
11614×
}
117
118
#[cfg(unix)]
119
/// The proxy for one job: listening until dropped.
120
pub struct Proxy {
121
    stop: Arc<AtomicBool>,
122
    path: std::path::PathBuf,
123
}
124
125
#[cfg(unix)]
126
impl Drop for Proxy {
1271×
    fn drop(&mut self) {
1281×
        self.stop.store(true, Ordering::SeqCst);
129
        // Wake the accept loop so it sees the stop.
1301×
        let _ = UnixStream::connect(&self.path);
1311×
        let _ = std::fs::remove_file(&self.path);
1321×
    }
133
}
134
135
#[cfg(unix)]
136
/// Listen on `socket` (Firecracker's `uds_path` and `_3128`) for the
137
/// guest's connections; each decision is sent to `log` as a line.
1381×
pub fn start(socket: &Path, allow: Vec<String>, log: mpsc::Sender<String>) -> std::io::Result<Proxy> {
1391×
    let _ = std::fs::remove_file(socket);
1401×
    let listener = UnixListener::bind(socket)?;
1411×
    let stop = Arc::new(AtomicBool::new(false));
1421×
    let open = Arc::new(AtomicUsize::new(0));
1431×
    let allow = Arc::new(allow);
1441×
    let s = stop.clone();
1451×
    std::thread::spawn(move || {
1463×
        for conn in listener.incoming() {
1473×
            if s.load(Ordering::SeqCst) {
1481×
                break;
1492×
            }
1502×
            let Ok(conn) = conn else { continue };
1512×
            if open.load(Ordering::SeqCst) >= MAX_OPEN {
1520×
                let _ = refuse(conn, "too many connections at once");
1530×
                let _ = log.send(format!("egress: refused a connection: {MAX_OPEN} already open"));
1540×
                continue;
1552×
            }
1562×
            open.fetch_add(1, Ordering::SeqCst);
1572×
            let (allow, log, open) = (allow.clone(), log.clone(), open.clone());
1582×
            std::thread::spawn(move || {
1592×
                serve(conn, &allow, &log);
1602×
                open.fetch_sub(1, Ordering::SeqCst);
1612×
            });
162
        }
1631×
    });
1641×
    Ok(Proxy { stop, path: socket.to_path_buf() })
1651×
}
166
167
#[cfg(unix)]
1682×
fn refuse(mut conn: UnixStream, why: &str) -> std::io::Result<()> {
1692×
    let body = format!("{why}\n");
1702×
    write!(conn, "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len())
1712×
}
172
173
#[cfg(unix)]
174
/// Read up to the blank line that ends a request head.
1752×
fn read_head(conn: &mut UnixStream) -> std::io::Result<String> {
1762×
    let mut head = Vec::new();
1772×
    let mut byte = [0u8; 1];
17885×
    while !head.ends_with(b"\r\n\r\n") {
17983×
        if head.len() >= MAX_HEAD || conn.read(&mut byte)? == 0 {
1800×
            break;
18183×
        }
18283×
        head.push(byte[0]);
183
    }
1842×
    Ok(String::from_utf8_lossy(&head).into_owned())
1852×
}
186
187
#[cfg(unix)]
1882×
fn serve(mut conn: UnixStream, allow: &[String], log: &mpsc::Sender<String>) {
1892×
    let _ = conn.set_read_timeout(Some(Duration::from_secs(30)));
1902×
    let head = match read_head(&mut conn) {
1912×
        Ok(h) => h,
1920×
        Err(_) => return,
193
    };
1942×
    let (host, port) = match parse_head(&head) {
1951×
        Ask::Connect { host, port } => (host, port),
1961×
        Ask::Refused(why) => {
1971×
            let _ = log.send(format!("egress: refused: {why}"));
1981×
            let _ = refuse(conn, &why);
1991×
            return;
200
        }
201
    };
2021×
    if let Err(why) = decide(allow, &host, port) {
2031×
        let _ = log.send(format!("egress: refused {host}:{port}: {why}"));
2041×
        let _ = refuse(conn, &format!("{host}:{port}: {why}"));
2051×
        return;
2060×
    }
2070×
    let addrs: Vec<SocketAddr> = match (host.as_str(), port).to_socket_addrs() {
2080×
        Ok(a) => a.collect(),
2090×
        Err(e) => {
2100×
            let _ = log.send(format!("egress: refused {host}:{port}: it could not be resolved ({e})"));
2110×
            let _ = refuse(conn, &format!("{host} could not be resolved"));
2120×
            return;
213
        }
214
    };
2150×
    if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) {
2160×
        let why = format!("it resolves to {}, which is not a public address", bad.ip());
2170×
        let _ = log.send(format!("egress: refused {host}:{port}: {why}"));
2180×
        let _ = refuse(conn, &format!("{host}: {why}"));
2190×
        return;
2200×
    }
2210×
    let out = addrs.iter().find_map(|a| TcpStream::connect_timeout(a, CONNECT_TIMEOUT).ok());
2220×
    let Some(out) = out else {
2230×
        let _ = log.send(format!("egress: {host}:{port} is allowed but did not answer"));
2240×
        let _ = conn.write_all(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
2250×
        return;
226
    };
2270×
    let _ = log.send(format!("egress: allowed {host}:{port}"));
2280×
    if conn.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").is_err() {
2290×
        return;
2300×
    }
2310×
    let _ = conn.set_read_timeout(None);
2320×
    relay(conn, out);
2332×
}
234
235
#[cfg(unix)]
236
/// Copy both ways until either side closes.
2370×
fn relay(guest: UnixStream, out: TcpStream) {
2380×
    let (Ok(mut guest_r), Ok(mut out_w)) = (guest.try_clone(), out.try_clone()) else { return };
2390×
    let up = std::thread::spawn(move || {
2400×
        let _ = std::io::copy(&mut guest_r, &mut out_w);
2410×
        let _ = out_w.shutdown(std::net::Shutdown::Write);
2420×
    });
2430×
    let (mut out_r, mut guest_w) = (out, guest);
2440×
    let _ = std::io::copy(&mut out_r, &mut guest_w);
2450×
    let _ = guest_w.shutdown(std::net::Shutdown::Write);
2460×
    let _ = up.join();
2470×
}
248
249
/// The egress proxy serves a microVM's vsock, which only Linux has.
250
#[cfg(not(unix))]
251
pub struct Proxy;
252
253
#[cfg(not(unix))]
254
pub fn start(_socket: &Path, _allow: Vec<String>, _log: mpsc::Sender<String>) -> std::io::Result<Proxy> {
255
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "egress serves microVMs, which run only on Linux"))
256
}
257
258
#[cfg(all(test, unix))]
259
mod tests {
260
    use super::*;
261
2626×
    fn allow() -> Vec<String> {
2636×
        vec!["*.googleapis.com".into(), "registry.opentofu.org".into()]
2646×
    }
265
266
    #[test]
2671×
    fn only_connect_to_an_allowed_name_on_443_is_let_through() {
2681×
        assert_eq!(
2691×
            parse_head("CONNECT storage.googleapis.com:443 HTTP/1.1\r\nHost: x\r\n\r\n"),
2701×
            Ask::Connect { host: "storage.googleapis.com".into(), port: 443 }
271
        );
2721×
        assert!(matches!(parse_head("GET http://example.com/ HTTP/1.1\r\n\r\n"), Ask::Refused(w) if w.contains("only https")));
2731×
        assert!(matches!(parse_head("CONNECT example.com HTTP/1.1\r\n\r\n"), Ask::Refused(w) if w.contains("no port")));
2741×
        assert_eq!(decide(&allow(), "storage.googleapis.com", 443), Ok(()));
2751×
        assert_eq!(decide(&allow(), "registry.opentofu.org", 443), Ok(()));
2761×
        assert!(decide(&allow(), "example.com", 443).unwrap_err().contains("allow-list"));
2771×
        assert!(decide(&allow(), "registry.opentofu.org", 22).unwrap_err().contains("only port 443"));
2781×
        assert!(decide(&allow(), "142.250.1.1", 443).unwrap_err().contains("names hosts"));
2791×
    }
280
281
    #[test]
2821×
    fn private_addresses_are_not_public() {
28311×
        for ip in [
2841×
            "127.0.0.1",
2851×
            "10.1.2.3",
2861×
            "192.168.1.1",
2871×
            "172.16.0.1",
2881×
            "169.254.169.254",
2891×
            "100.64.0.1",
2901×
            "0.0.0.0",
2911×
            "::1",
2921×
            "fd00::1",
2931×
            "fe80::1",
2941×
            "::ffff:10.0.0.1",
2951×
        ] {
29611×
            assert!(!public(ip.parse().unwrap()), "{ip}");
297
        }
2982×
        for ip in ["142.250.1.1", "2a00:1450:4009::200e"] {
2992×
            assert!(public(ip.parse().unwrap()), "{ip}");
300
        }
3011×
    }
302
303
    #[test]
3041×
    fn the_proxy_refuses_and_logs_a_host_not_allowed() {
3051×
        let dir = tempfile::tempdir().unwrap();
3061×
        let sock = dir.path().join("v.sock_3128");
3071×
        let (tx, rx) = mpsc::channel();
3081×
        let proxy = start(&sock, allow(), tx).unwrap();
3092×
        let ask = |head: &str| {
3102×
            let mut c = UnixStream::connect(&sock).unwrap();
3112×
            c.write_all(head.as_bytes()).unwrap();
3122×
            let mut answer = String::new();
3132×
            c.read_to_string(&mut answer).unwrap();
3142×
            answer
3152×
        };
3161×
        let a = ask("CONNECT example.com:443 HTTP/1.1\r\n\r\n");
3171×
        assert!(a.starts_with("HTTP/1.1 403") && a.contains("not in the sandbox's egress allow-list"), "{a}");
3181×
        assert_eq!(
3191×
            rx.recv_timeout(Duration::from_secs(5)).unwrap(),
320
            "egress: refused example.com:443: not in the sandbox's egress allow-list"
321
        );
3221×
        let a = ask("GET http://storage.googleapis.com/ HTTP/1.1\r\n\r\n");
3231×
        assert!(a.starts_with("HTTP/1.1 403"), "{a}");
3241×
        assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().contains("only https"));
3251×
        drop(proxy);
3261×
        assert!(!sock.exists(), "the socket goes with the job");
3271×
    }
328
}