OXYGENHUB
◆ proposal-list-fast oxygenhub / crates / hub / src / net.rs
6.5 KiB · Rust · last changed Outbound: localhost names are refused without asking the resolver 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
    // `localhost` names are loopback by definition (RFC 6761), whatever
80
    // the resolver says or whether it knows them.
81
    let lower = host.to_ascii_lowercase();
82
    if lower == "localhost" || lower.ends_with(".localhost") {
83
        return Err(format!(
84
            "{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)"
85
        ));
86
    }
87
    let addrs: Vec<SocketAddr> = match host.parse::<IpAddr>() {
88
        Ok(ip) => vec![SocketAddr::new(ip, port)],
89
        Err(_) => tokio::net::lookup_host((host.as_str(), port)).await.map_err(|_| format!("{host} could not be found"))?.collect(),
90
    };
91
    if addrs.is_empty() {
92
        return Err(format!("{host} has no address"));
93
    }
94
    if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) {
95
        return Err(format!(
96
            "{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)",
97
            bad.ip()
98
        ));
99
    }
100
    builder.resolve_to_addrs(&host, &addrs).build().map_err(|e| e.to_string())
101
}
102
103
#[cfg(test)]
104
mod tests {
105
    use super::*;
106
107
    #[test]
108
    fn only_public_addresses_are_public() {
109
        for bad in [
110
            "127.0.0.1",
111
            "10.1.2.3",
112
            "172.16.0.1",
113
            "192.168.1.1",
114
            "169.254.169.254",
115
            "100.64.0.1",
116
            "0.0.0.0",
117
            "224.0.0.1",
118
            "255.255.255.255",
119
            "::1",
120
            "::",
121
            "fc00::1",
122
            "fd12:3456::1",
123
            "fe80::1",
124
            "::ffff:127.0.0.1",
125
            "::ffff:10.0.0.1",
126
            "64:ff9b::a9fe:a9fe",
127
            "2001:db8::1",
128
        ] {
129
            assert!(!public(bad.parse().unwrap()), "{bad} is not public");
130
        }
131
        for good in ["1.1.1.1", "8.8.8.8", "104.21.68.34", "2606:4700:4700::1111", "::ffff:8.8.8.8"] {
132
            assert!(public(good.parse().unwrap()), "{good} is public");
133
        }
134
    }
135
136
    #[test]
137
    fn an_allow_entry_matches_its_host_and_port() {
138
        let allow = vec!["http://127.0.0.1:11434".to_string(), "http://ollama.lan".to_string()];
139
        let ok = |u: &str| allowed(&allow, &reqwest::Url::parse(u).unwrap());
140
        assert!(ok("http://127.0.0.1:11434/v1/chat/completions") && ok("http://ollama.lan:8080/x"));
141
        assert!(!ok("http://127.0.0.1:6379/") && !ok("https://127.0.0.1:11434/") && !ok("http://other.lan/"));
142
    }
143
144
    #[tokio::test]
145
    async fn private_addresses_are_refused_with_a_reason() {
146
        for u in [
147
            "http://127.0.0.1:9/",
148
            "http://169.254.169.254/latest/meta-data/",
149
            "http://[::1]/",
150
            "http://10.0.0.5/hook",
151
            "http://localhost:8080/",
152
            "http://app.LOCALHOST/",
153
        ] {
154
            let e = client(u, &[], Duration::from_secs(1)).await.unwrap_err();
155
            assert!(e.contains("private or local address"), "{u}: {e}");
156
        }
157
        assert!(client("ftp://example.org/", &[], Duration::from_secs(1)).await.is_err());
158
        assert!(client("http://127.0.0.1:11434/", &["http://127.0.0.1:11434".into()], Duration::from_secs(1)).await.is_ok());
159
    }
160
}