Skip to main content

koi_proxy/
safety.rs

1use std::net::ToSocketAddrs;
2
3use crate::ProxyError;
4
5/// Parse a backend into `(host, port)`.
6///
7/// Accepts either a URL with an explicit or scheme-default port
8/// (`http://127.0.0.1:3000`, `https://host`) or a bare `host:port`
9/// (`127.0.0.1:3000`). The passthrough connects to this TCP endpoint directly;
10/// any URL path is irrelevant to a byte-level proxy and is ignored.
11pub fn parse_backend(backend: &str) -> Result<(String, u16), ProxyError> {
12    if backend.contains("://") {
13        let url = url::Url::parse(backend)
14            .map_err(|e| ProxyError::InvalidConfig(format!("invalid backend URL: {e}")))?;
15        let host = url
16            .host_str()
17            .ok_or_else(|| ProxyError::InvalidConfig("backend URL missing host".to_string()))?
18            .to_string();
19        let port = url
20            .port_or_known_default()
21            .ok_or_else(|| ProxyError::InvalidConfig("backend URL missing port".to_string()))?;
22        return Ok((host, port));
23    }
24
25    let (host, port) = backend.rsplit_once(':').ok_or_else(|| {
26        ProxyError::InvalidConfig("backend must be host:port or a URL".to_string())
27    })?;
28    let port: u16 = port
29        .parse()
30        .map_err(|_| ProxyError::InvalidConfig(format!("invalid backend port: {port}")))?;
31    if host.is_empty() {
32        return Err(ProxyError::InvalidConfig(
33            "backend missing host".to_string(),
34        ));
35    }
36    Ok((host.to_string(), port))
37}
38
39/// Reject a non-loopback backend unless `allow_remote` is set.
40///
41/// The plaintext hop from the proxy to its backend is unencrypted, so by default
42/// the backend must be loopback. `--backend-remote` / `allow_remote` opts into a
43/// remote backend (with a loud warning at the call site).
44pub fn ensure_backend_allowed(backend: &str, allow_remote: bool) -> Result<(), ProxyError> {
45    let (host, port) = parse_backend(backend)?;
46
47    if allow_remote {
48        return Ok(());
49    }
50
51    if host.eq_ignore_ascii_case("localhost") {
52        return Ok(());
53    }
54
55    let addrs = (host.as_str(), port)
56        .to_socket_addrs()
57        .map_err(|e| ProxyError::InvalidConfig(format!("backend resolution failed: {e}")))?;
58
59    let mut any = false;
60    for addr in addrs {
61        any = true;
62        if !addr.ip().is_loopback() {
63            return Err(ProxyError::InvalidConfig(
64                "backend is not loopback; use --backend-remote to allow".to_string(),
65            ));
66        }
67    }
68
69    if !any {
70        return Err(ProxyError::InvalidConfig(
71            "backend host did not resolve to any address".to_string(),
72        ));
73    }
74
75    Ok(())
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn parses_url_form_with_explicit_port() {
84        let (host, port) = parse_backend("http://127.0.0.1:3000").unwrap();
85        assert_eq!(host, "127.0.0.1");
86        assert_eq!(port, 3000);
87    }
88
89    #[test]
90    fn parses_url_form_with_default_port() {
91        let (host, port) = parse_backend("https://example.test").unwrap();
92        assert_eq!(host, "example.test");
93        assert_eq!(port, 443);
94    }
95
96    #[test]
97    fn parses_bare_host_port() {
98        let (host, port) = parse_backend("127.0.0.1:8080").unwrap();
99        assert_eq!(host, "127.0.0.1");
100        assert_eq!(port, 8080);
101    }
102
103    #[test]
104    fn rejects_missing_port() {
105        assert!(parse_backend("127.0.0.1").is_err());
106    }
107
108    #[test]
109    fn loopback_backend_allowed_without_remote() {
110        assert!(ensure_backend_allowed("127.0.0.1:3000", false).is_ok());
111        assert!(ensure_backend_allowed("http://localhost:3000", false).is_ok());
112    }
113
114    #[test]
115    fn non_loopback_backend_rejected_without_remote() {
116        // 192.0.2.0/24 is TEST-NET-1 (RFC 5737), never loopback.
117        assert!(ensure_backend_allowed("192.0.2.10:3000", false).is_err());
118    }
119
120    #[test]
121    fn non_loopback_backend_allowed_with_remote() {
122        assert!(ensure_backend_allowed("192.0.2.10:3000", true).is_ok());
123    }
124}