//! Outbound requests to addresses people give (oxygenhub#219): webhooks //! and AI assistants. The hub only calls public addresses: the host is //! resolved, every address it resolves to must be public, and the //! connection is pinned to those addresses (so a second lookup cannot //! point it elsewhere); redirects are not followed. A site administrator //! may allow a private address on purpose (`[outbound] allow` in //! hub.toml), such as a local Ollama. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::time::Duration; /// Whether `ip` is an address on the public internet. pub fn public(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => public_v4(v4), IpAddr::V6(v6) => public_v6(v6), } } fn public_v4(ip: Ipv4Addr) -> bool { let [a, b, c, _] = ip.octets(); !(ip.is_loopback() || ip.is_private() || ip.is_link_local() // 169.254/16, the cloud metadata address among them || ip.is_multicast() || ip.is_unspecified() || ip.is_broadcast() || ip.is_documentation() || a == 0 || (a == 100 && (64..128).contains(&b)) // shared address space (CGNAT) || (a == 192 && b == 0 && c == 0) || (a == 198 && (b == 18 || b == 19)) // benchmarking || a >= 240) } fn public_v6(ip: Ipv6Addr) -> bool { if let Some(v4) = ip.to_ipv4_mapped() { return public_v4(v4); } let s = ip.segments(); // NAT64 (64:ff9b::/96) carries an IPv4 address: judge that. if s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] { return public_v4(Ipv4Addr::new((s[6] >> 8) as u8, s[6] as u8, (s[7] >> 8) as u8, s[7] as u8)); } !(ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() || (s[0] & 0xfe00) == 0xfc00 // unique local || (s[0] & 0xffc0) == 0xfe80 // link local || (s[0] == 0x2001 && s[1] == 0x0db8)) // documentation } /// Whether `url` is one a site administrator allowed: an `allow` entry /// `scheme://host` matches any port, `scheme://host:port` that port. pub fn allowed(allow: &[String], url: &reqwest::Url) -> bool { allow.iter().filter_map(|a| reqwest::Url::parse(a).ok()).any(|a| { a.scheme() == url.scheme() && a.host_str().map(str::to_ascii_lowercase) == url.host_str().map(str::to_ascii_lowercase) && (a.port().is_none() || a.port_or_known_default() == url.port_or_known_default()) }) } /// A client for one request to `url`: refused, with a plain reason, when /// the address is not public and not allowed. pub async fn client(url: &str, allow: &[String], timeout: Duration) -> Result { let parsed = reqwest::Url::parse(url).map_err(|_| format!("{url} is not an address"))?; if !matches!(parsed.scheme(), "http" | "https") { return Err("only http and https addresses are called".into()); } let host = parsed.host_str().ok_or("the address has no host")?.trim_start_matches('[').trim_end_matches(']').to_string(); let port = parsed.port_or_known_default().unwrap_or(443); let builder = reqwest::Client::builder() .timeout(timeout) .redirect(reqwest::redirect::Policy::none()) .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))); if allowed(allow, &parsed) { return builder.build().map_err(|e| e.to_string()); } let addrs: Vec = match host.parse::() { Ok(ip) => vec![SocketAddr::new(ip, port)], Err(_) => tokio::net::lookup_host((host.as_str(), port)).await.map_err(|_| format!("{host} could not be found"))?.collect(), }; if addrs.is_empty() { return Err(format!("{host} has no address")); } if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) { return Err(format!( "{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)", bad.ip() )); } builder.resolve_to_addrs(&host, &addrs).build().map_err(|e| e.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn only_public_addresses_are_public() { for bad in [ "127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:127.0.0.1", "::ffff:10.0.0.1", "64:ff9b::a9fe:a9fe", "2001:db8::1", ] { assert!(!public(bad.parse().unwrap()), "{bad} is not public"); } for good in ["1.1.1.1", "8.8.8.8", "104.21.68.34", "2606:4700:4700::1111", "::ffff:8.8.8.8"] { assert!(public(good.parse().unwrap()), "{good} is public"); } } #[test] fn an_allow_entry_matches_its_host_and_port() { let allow = vec!["http://127.0.0.1:11434".to_string(), "http://ollama.lan".to_string()]; let ok = |u: &str| allowed(&allow, &reqwest::Url::parse(u).unwrap()); assert!(ok("http://127.0.0.1:11434/v1/chat/completions") && ok("http://ollama.lan:8080/x")); assert!(!ok("http://127.0.0.1:6379/") && !ok("https://127.0.0.1:11434/") && !ok("http://other.lan/")); } #[tokio::test] async fn private_addresses_are_refused_with_a_reason() { for u in [ "http://127.0.0.1:9/", "http://169.254.169.254/latest/meta-data/", "http://[::1]/", "http://10.0.0.5/hook", "http://localhost:8080/", ] { let e = client(u, &[], Duration::from_secs(1)).await.unwrap_err(); assert!(e.contains("private or local address"), "{u}: {e}"); } assert!(client("ftp://example.org/", &[], Duration::from_secs(1)).await.is_err()); assert!(client("http://127.0.0.1:11434/", &["http://127.0.0.1:11434".into()], Duration::from_secs(1)).await.is_ok()); } }