OXYGENHUB

Coverage / crates/hub/src/net.rs 94.7%

125 of 132 lines run, 20 of 31 functions · run #515 on main · run missed

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.
1327×
pub fn public(ip: IpAddr) -> bool {
1427×
    match ip {
1515×
        IpAddr::V4(v4) => public_v4(v4),
1612×
        IpAddr::V6(v6) => public_v6(v6),
17
    }
1827×
}
19
2019×
fn public_v4(ip: Ipv4Addr) -> bool {
2119×
    let [a, b, c, _] = ip.octets();
2219×
    !(ip.is_loopback()
2316×
        || ip.is_private()
2411×
        || ip.is_link_local() // 169.254/16, the cloud metadata address among them
258×
        || ip.is_multicast()
267×
        || ip.is_unspecified()
276×
        || ip.is_broadcast()
285×
        || ip.is_documentation()
295×
        || a == 0
305×
        || (a == 100 && (64..128).contains(&b)) // shared address space (CGNAT)
314×
        || (a == 192 && b == 0 && c == 0)
324×
        || (a == 198 && (b == 18 || b == 19)) // benchmarking
334×
        || a >= 240)
3419×
}
35
3612×
fn public_v6(ip: Ipv6Addr) -> bool {
3712×
    if let Some(v4) = ip.to_ipv4_mapped() {
383×
        return public_v4(v4);
399×
    }
409×
    let s = ip.segments();
41
    // NAT64 (64:ff9b::/96) carries an IPv4 address: judge that.
429×
    if s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
431×
        return public_v4(Ipv4Addr::new((s[6] >> 8) as u8, s[6] as u8, (s[7] >> 8) as u8, s[7] as u8));
448×
    }
458×
    !(ip.is_loopback()
466×
        || ip.is_unspecified()
475×
        || ip.is_multicast()
485×
        || (s[0] & 0xfe00) == 0xfc00 // unique local
493×
        || (s[0] & 0xffc0) == 0xfe80 // link local
502×
        || (s[0] == 0x2001 && s[1] == 0x0db8)) // documentation
5112×
}
52
53
/// Whether `url` is one a site administrator allowed: an `allow` entry
54
/// `scheme://host` matches any port, `scheme://host:port` that port.
5558×
pub fn allowed(allow: &[String], url: &reqwest::Url) -> bool {
5658×
    allow.iter().filter_map(|a| reqwest::Url::parse(a).ok()).any(|a| {
5755×
        a.scheme() == url.scheme()
5852×
            && a.host_str().map(str::to_ascii_lowercase) == url.host_str().map(str::to_ascii_lowercase)
5948×
            && (a.port().is_none() || a.port_or_known_default() == url.port_or_known_default())
6055×
    })
6158×
}
62
63
/// A client for one request to `url`: refused, with a plain reason, when
64
/// the address is not public and not allowed.
6554×
pub async fn client(url: &str, allow: &[String], timeout: Duration) -> Result<reqwest::Client, String> {
6654×
    let parsed = reqwest::Url::parse(url).map_err(|_| format!("{url} is not an address"))?;
6754×
    if !matches!(parsed.scheme(), "http" | "https") {
681×
        return Err("only http and https addresses are called".into());
6953×
    }
7053×
    let host = parsed.host_str().ok_or("the address has no host")?.trim_start_matches('[').trim_end_matches(']').to_string();
7153×
    let port = parsed.port_or_known_default().unwrap_or(443);
7253×
    let builder = reqwest::Client::builder()
7353×
        .timeout(timeout)
7453×
        .redirect(reqwest::redirect::Policy::none())
7553×
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")));
7653×
    if allowed(allow, &parsed) {
7745×
        return builder.build().map_err(|e| e.to_string());
788×
    }
79
    // `localhost` names are loopback by definition (RFC 6761), whatever
80
    // the resolver says or whether it knows them.
818×
    let lower = host.to_ascii_lowercase();
828×
    if lower == "localhost" || lower.ends_with(".localhost") {
832×
        return Err(format!(
842×
            "{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)"
852×
        ));
866×
    }
876×
    let addrs: Vec<SocketAddr> = match host.parse::<IpAddr>() {
884×
        Ok(ip) => vec![SocketAddr::new(ip, port)],
892×
        Err(_) => tokio::net::lookup_host((host.as_str(), port)).await.map_err(|_| format!("{host} could not be found"))?.collect(),
90
    };
914×
    if addrs.is_empty() {
920×
        return Err(format!("{host} has no address"));
934×
    }
944×
    if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) {
954×
        return Err(format!(
964×
            "{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)",
974×
            bad.ip()
984×
        ));
990×
    }
1000×
    builder.resolve_to_addrs(&host, &addrs).build().map_err(|e| e.to_string())
10153×
}
102
103
#[cfg(test)]
104
mod tests {
105
    use super::*;
106
107
    #[test]
1081×
    fn only_public_addresses_are_public() {
10918×
        for bad in [
1101×
            "127.0.0.1",
1111×
            "10.1.2.3",
1121×
            "172.16.0.1",
1131×
            "192.168.1.1",
1141×
            "169.254.169.254",
1151×
            "100.64.0.1",
1161×
            "0.0.0.0",
1171×
            "224.0.0.1",
1181×
            "255.255.255.255",
1191×
            "::1",
1201×
            "::",
1211×
            "fc00::1",
1221×
            "fd12:3456::1",
1231×
            "fe80::1",
1241×
            "::ffff:127.0.0.1",
1251×
            "::ffff:10.0.0.1",
1261×
            "64:ff9b::a9fe:a9fe",
1271×
            "2001:db8::1",
1281×
        ] {
12918×
            assert!(!public(bad.parse().unwrap()), "{bad} is not public");
130
        }
1315×
        for good in ["1.1.1.1", "8.8.8.8", "104.21.68.34", "2606:4700:4700::1111", "::ffff:8.8.8.8"] {
1325×
            assert!(public(good.parse().unwrap()), "{good} is public");
133
        }
1341×
    }
135
136
    #[test]
1371×
    fn an_allow_entry_matches_its_host_and_port() {
1381×
        let allow = vec!["http://127.0.0.1:11434".to_string(), "http://ollama.lan".to_string()];
1395×
        let ok = |u: &str| allowed(&allow, &reqwest::Url::parse(u).unwrap());
1401×
        assert!(ok("http://127.0.0.1:11434/v1/chat/completions") && ok("http://ollama.lan:8080/x"));
1411×
        assert!(!ok("http://127.0.0.1:6379/") && !ok("https://127.0.0.1:11434/") && !ok("http://other.lan/"));
1421×
    }
143
144
    #[tokio::test]
1451×
    async fn private_addresses_are_refused_with_a_reason() {
1466×
        for u in [
1471×
            "http://127.0.0.1:9/",
1481×
            "http://169.254.169.254/latest/meta-data/",
1491×
            "http://[::1]/",
1501×
            "http://10.0.0.5/hook",
1511×
            "http://localhost:8080/",
1521×
            "http://app.LOCALHOST/",
1531×
        ] {
1546×
            let e = client(u, &[], Duration::from_secs(1)).await.unwrap_err();
1556×
            assert!(e.contains("private or local address"), "{u}: {e}");
156
        }
1571×
        assert!(client("ftp://example.org/", &[], Duration::from_secs(1)).await.is_err());
1581×
        assert!(client("http://127.0.0.1:11434/", &["http://127.0.0.1:11434".into()], Duration::from_secs(1)).await.is_ok());
1591×
    }
160
}