OXYGENHUB
# 046e3352cdfd oxygenhub / crates / hub / src / net.rs
6.0 KiB · Rust · last changed Outbound requests only to public addresses 20 days ago source blame history raw
1
//! Outbound requests to addresses people give (oxygenhub#219): webhooks
2
//! and AI assistants. The hub only calls public addresses: the host is
3
//! resolved, every address it resolves to must be public, and the
4
//! connection is pinned to those addresses (so a second lookup cannot
5
//! point it elsewhere); redirects are not followed. A site administrator
6
//! may allow a private address on purpose (`[outbound] allow` in
7
//! hub.toml), such as a local Ollama.
8
9
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
10
use std::time::Duration;
11
12
/// Whether `ip` is an address on the public internet.
13
pub fn public(ip: IpAddr) -> bool {
14
    match ip {
15
        IpAddr::V4(v4) => public_v4(v4),
16
        IpAddr::V6(v6) => public_v6(v6),
17
    }
18
}
19
20
fn public_v4(ip: Ipv4Addr) -> bool {
21
    let [a, b, c, _] = ip.octets();
22
    !(ip.is_loopback()
23
        || ip.is_private()
24
        || ip.is_link_local() // 169.254/16, the cloud metadata address among them
25
        || ip.is_multicast()
26
        || ip.is_unspecified()
27
        || ip.is_broadcast()
28
        || ip.is_documentation()
29
        || a == 0
30
        || (a == 100 && (64..128).contains(&b)) // shared address space (CGNAT)
31
        || (a == 192 && b == 0 && c == 0)
32
        || (a == 198 && (b == 18 || b == 19)) // benchmarking
33
        || a >= 240)
34
}
35
36
fn public_v6(ip: Ipv6Addr) -> bool {
37
    if let Some(v4) = ip.to_ipv4_mapped() {
38
        return public_v4(v4);
39
    }
40
    let s = ip.segments();
41
    // NAT64 (64:ff9b::/96) carries an IPv4 address: judge that.
42
    if s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
43
        return public_v4(Ipv4Addr::new((s[6] >> 8) as u8, s[6] as u8, (s[7] >> 8) as u8, s[7] as u8));
44
    }
45
    !(ip.is_loopback()
46
        || ip.is_unspecified()
47
        || ip.is_multicast()
48
        || (s[0] & 0xfe00) == 0xfc00 // unique local
49
        || (s[0] & 0xffc0) == 0xfe80 // link local
50
        || (s[0] == 0x2001 && s[1] == 0x0db8)) // documentation
51
}
52
53
/// Whether `url` is one a site administrator allowed: an `allow` entry
54
/// `scheme://host` matches any port, `scheme://host:port` that port.
55
pub fn allowed(allow: &[String], url: &reqwest::Url) -> bool {
56
    allow.iter().filter_map(|a| reqwest::Url::parse(a).ok()).any(|a| {
57
        a.scheme() == url.scheme()
58
            && a.host_str().map(str::to_ascii_lowercase) == url.host_str().map(str::to_ascii_lowercase)
59
            && (a.port().is_none() || a.port_or_known_default() == url.port_or_known_default())
60
    })
61
}
62
63
/// A client for one request to `url`: refused, with a plain reason, when
64
/// the address is not public and not allowed.
65
pub async fn client(url: &str, allow: &[String], timeout: Duration) -> Result<reqwest::Client, String> {
66
    let parsed = reqwest::Url::parse(url).map_err(|_| format!("{url} is not an address"))?;
67
    if !matches!(parsed.scheme(), "http" | "https") {
68
        return Err("only http and https addresses are called".into());
69
    }
70
    let host = parsed.host_str().ok_or("the address has no host")?.trim_start_matches('[').trim_end_matches(']').to_string();
71
    let port = parsed.port_or_known_default().unwrap_or(443);
72
    let builder = reqwest::Client::builder()
73
        .timeout(timeout)
74
        .redirect(reqwest::redirect::Policy::none())
75
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")));
76
    if allowed(allow, &parsed) {
77
        return builder.build().map_err(|e| e.to_string());
78
    }
79
    let addrs: Vec<SocketAddr> = match host.parse::<IpAddr>() {
80
        Ok(ip) => vec![SocketAddr::new(ip, port)],
81
        Err(_) => tokio::net::lookup_host((host.as_str(), port)).await.map_err(|_| format!("{host} could not be found"))?.collect(),
82
    };
83
    if addrs.is_empty() {
84
        return Err(format!("{host} has no address"));
85
    }
86
    if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) {
87
        return Err(format!(
88
            "{host} is {}, a private or local address; the hub only calls public addresses (a site administrator can allow one with [outbound] allow in hub.toml)",
89
            bad.ip()
90
        ));
91
    }
92
    builder.resolve_to_addrs(&host, &addrs).build().map_err(|e| e.to_string())
93
}
94
95
#[cfg(test)]
96
mod tests {
97
    use super::*;
98
99
    #[test]
100
    fn only_public_addresses_are_public() {
101
        for bad in [
102
            "127.0.0.1",
103
            "10.1.2.3",
104
            "172.16.0.1",
105
            "192.168.1.1",
106
            "169.254.169.254",
107
            "100.64.0.1",
108
            "0.0.0.0",
109
            "224.0.0.1",
110
            "255.255.255.255",
111
            "::1",
112
            "::",
113
            "fc00::1",
114
            "fd12:3456::1",
115
            "fe80::1",
116
            "::ffff:127.0.0.1",
117
            "::ffff:10.0.0.1",
118
            "64:ff9b::a9fe:a9fe",
119
            "2001:db8::1",
120
        ] {
121
            assert!(!public(bad.parse().unwrap()), "{bad} is not public");
122
        }
123
        for good in ["1.1.1.1", "8.8.8.8", "104.21.68.34", "2606:4700:4700::1111", "::ffff:8.8.8.8"] {
124
            assert!(public(good.parse().unwrap()), "{good} is public");
125
        }
126
    }
127
128
    #[test]
129
    fn an_allow_entry_matches_its_host_and_port() {
130
        let allow = vec!["http://127.0.0.1:11434".to_string(), "http://ollama.lan".to_string()];
131
        let ok = |u: &str| allowed(&allow, &reqwest::Url::parse(u).unwrap());
132
        assert!(ok("http://127.0.0.1:11434/v1/chat/completions") && ok("http://ollama.lan:8080/x"));
133
        assert!(!ok("http://127.0.0.1:6379/") && !ok("https://127.0.0.1:11434/") && !ok("http://other.lan/"));
134
    }
135
136
    #[tokio::test]
137
    async fn private_addresses_are_refused_with_a_reason() {
138
        for u in [
139
            "http://127.0.0.1:9/",
140
            "http://169.254.169.254/latest/meta-data/",
141
            "http://[::1]/",
142
            "http://10.0.0.5/hook",
143
            "http://localhost:8080/",
144
        ] {
145
            let e = client(u, &[], Duration::from_secs(1)).await.unwrap_err();
146
            assert!(e.contains("private or local address"), "{u}: {e}");
147
        }
148
        assert!(client("ftp://example.org/", &[], Duration::from_secs(1)).await.is_err());
149
        assert!(client("http://127.0.0.1:11434/", &["http://127.0.0.1:11434".into()], Duration::from_secs(1)).await.is_ok());
150
    }
151
}