| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | | use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; |
| 10 | | use std::time::Duration; |
| 11 | |
|
| 12 | |
|
| 13 | 27× | pub fn public(ip: IpAddr) -> bool { |
| 14 | 27× | match ip { |
| 15 | 15× | IpAddr::V4(v4) => public_v4(v4), |
| 16 | 12× | IpAddr::V6(v6) => public_v6(v6), |
| 17 | | } |
| 18 | 27× | } |
| 19 | |
|
| 20 | 19× | fn public_v4(ip: Ipv4Addr) -> bool { |
| 21 | 19× | let [a, b, c, _] = ip.octets(); |
| 22 | 19× | !(ip.is_loopback() |
| 23 | 16× | || ip.is_private() |
| 24 | 11× | || ip.is_link_local() |
| 25 | 8× | || ip.is_multicast() |
| 26 | 7× | || ip.is_unspecified() |
| 27 | 6× | || ip.is_broadcast() |
| 28 | 5× | || ip.is_documentation() |
| 29 | 5× | || a == 0 |
| 30 | 5× | || (a == 100 && (64..128).contains(&b)) |
| 31 | 4× | || (a == 192 && b == 0 && c == 0) |
| 32 | 4× | || (a == 198 && (b == 18 || b == 19)) |
| 33 | 4× | || a >= 240) |
| 34 | 19× | } |
| 35 | |
|
| 36 | 12× | fn public_v6(ip: Ipv6Addr) -> bool { |
| 37 | 12× | if let Some(v4) = ip.to_ipv4_mapped() { |
| 38 | 3× | return public_v4(v4); |
| 39 | 9× | } |
| 40 | 9× | let s = ip.segments(); |
| 41 | | |
| 42 | 9× | if s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] { |
| 43 | 1× | return public_v4(Ipv4Addr::new((s[6] >> 8) as u8, s[6] as u8, (s[7] >> 8) as u8, s[7] as u8)); |
| 44 | 8× | } |
| 45 | 8× | !(ip.is_loopback() |
| 46 | 6× | || ip.is_unspecified() |
| 47 | 5× | || ip.is_multicast() |
| 48 | 5× | || (s[0] & 0xfe00) == 0xfc00 |
| 49 | 3× | || (s[0] & 0xffc0) == 0xfe80 |
| 50 | 2× | || (s[0] == 0x2001 && s[1] == 0x0db8)) |
| 51 | 12× | } |
| 52 | |
|
| 53 | |
|
| 54 | |
|
| 55 | 61× | pub fn allowed(allow: &[String], url: &reqwest::Url) -> bool { |
| 56 | 61× | allow.iter().filter_map(|a| reqwest::Url::parse(a).ok()).any(|a| { |
| 57 | 58× | a.scheme() == url.scheme() |
| 58 | 55× | && a.host_str().map(str::to_ascii_lowercase) == url.host_str().map(str::to_ascii_lowercase) |
| 59 | 51× | && (a.port().is_none() || a.port_or_known_default() == url.port_or_known_default()) |
| 60 | 58× | }) |
| 61 | 61× | } |
| 62 | |
|
| 63 | |
|
| 64 | |
|
| 65 | 57× | pub async fn client(url: &str, allow: &[String], timeout: Duration) -> Result<reqwest::Client, String> { |
| 66 | 57× | let parsed = reqwest::Url::parse(url).map_err(|_| format!("{url} is not an address"))?; |
| 67 | 57× | if !matches!(parsed.scheme(), "http" | "https") { |
| 68 | 1× | return Err("only http and https addresses are called".into()); |
| 69 | 56× | } |
| 70 | 56× | let host = parsed.host_str().ok_or("the address has no host")?.trim_start_matches('[').trim_end_matches(']').to_string(); |
| 71 | 56× | let port = parsed.port_or_known_default().unwrap_or(443); |
| 72 | 56× | let builder = reqwest::Client::builder() |
| 73 | 56× | .timeout(timeout) |
| 74 | 56× | .redirect(reqwest::redirect::Policy::none()) |
| 75 | 56× | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))); |
| 76 | 56× | if allowed(allow, &parsed) { |
| 77 | 48× | return builder.build().map_err(|e| e.to_string()); |
| 78 | 8× | } |
| 79 | | |
| 80 | | |
| 81 | 8× | let lower = host.to_ascii_lowercase(); |
| 82 | 8× | if lower == "localhost" || lower.ends_with(".localhost") { |
| 83 | 2× | return Err(format!( |
| 84 | 2× | "{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 | 2× | )); |
| 86 | 6× | } |
| 87 | 6× | let addrs: Vec<SocketAddr> = match host.parse::<IpAddr>() { |
| 88 | 4× | Ok(ip) => vec![SocketAddr::new(ip, port)], |
| 89 | 2× | Err(_) => tokio::net::lookup_host((host.as_str(), port)).await.map_err(|_| format!("{host} could not be found"))?.collect(), |
| 90 | | }; |
| 91 | 4× | if addrs.is_empty() { |
| 92 | 0× | return Err(format!("{host} has no address")); |
| 93 | 4× | } |
| 94 | 4× | if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) { |
| 95 | 4× | return Err(format!( |
| 96 | 4× | "{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 | 4× | bad.ip() |
| 98 | 4× | )); |
| 99 | 0× | } |
| 100 | 0× | builder.resolve_to_addrs(&host, &addrs).build().map_err(|e| e.to_string()) |
| 101 | 56× | } |
| 102 | |
|
| 103 | | #[cfg(test)] |
| 104 | | mod tests { |
| 105 | | use super::*; |
| 106 | |
|
| 107 | | #[test] |
| 108 | 1× | fn only_public_addresses_are_public() { |
| 109 | 18× | for bad in [ |
| 110 | 1× | "127.0.0.1", |
| 111 | 1× | "10.1.2.3", |
| 112 | 1× | "172.16.0.1", |
| 113 | 1× | "192.168.1.1", |
| 114 | 1× | "169.254.169.254", |
| 115 | 1× | "100.64.0.1", |
| 116 | 1× | "0.0.0.0", |
| 117 | 1× | "224.0.0.1", |
| 118 | 1× | "255.255.255.255", |
| 119 | 1× | "::1", |
| 120 | 1× | "::", |
| 121 | 1× | "fc00::1", |
| 122 | 1× | "fd12:3456::1", |
| 123 | 1× | "fe80::1", |
| 124 | 1× | "::ffff:127.0.0.1", |
| 125 | 1× | "::ffff:10.0.0.1", |
| 126 | 1× | "64:ff9b::a9fe:a9fe", |
| 127 | 1× | "2001:db8::1", |
| 128 | 1× | ] { |
| 129 | 18× | assert!(!public(bad.parse().unwrap()), "{bad} is not public"); |
| 130 | | } |
| 131 | 5× | 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 | 5× | assert!(public(good.parse().unwrap()), "{good} is public"); |
| 133 | | } |
| 134 | 1× | } |
| 135 | |
|
| 136 | | #[test] |
| 137 | 1× | fn an_allow_entry_matches_its_host_and_port() { |
| 138 | 1× | let allow = vec!["http://127.0.0.1:11434".to_string(), "http://ollama.lan".to_string()]; |
| 139 | 5× | let ok = |u: &str| allowed(&allow, &reqwest::Url::parse(u).unwrap()); |
| 140 | 1× | assert!(ok("http://127.0.0.1:11434/v1/chat/completions") && ok("http://ollama.lan:8080/x")); |
| 141 | 1× | assert!(!ok("http://127.0.0.1:6379/") && !ok("https://127.0.0.1:11434/") && !ok("http://other.lan/")); |
| 142 | 1× | } |
| 143 | |
|
| 144 | | #[tokio::test] |
| 145 | 1× | async fn private_addresses_are_refused_with_a_reason() { |
| 146 | 6× | for u in [ |
| 147 | 1× | "http://127.0.0.1:9/", |
| 148 | 1× | "http://169.254.169.254/latest/meta-data/", |
| 149 | 1× | "http://[::1]/", |
| 150 | 1× | "http://10.0.0.5/hook", |
| 151 | 1× | "http://localhost:8080/", |
| 152 | 1× | "http://app.LOCALHOST/", |
| 153 | 1× | ] { |
| 154 | 6× | let e = client(u, &[], Duration::from_secs(1)).await.unwrap_err(); |
| 155 | 6× | assert!(e.contains("private or local address"), "{u}: {e}"); |
| 156 | | } |
| 157 | 1× | assert!(client("ftp://example.org/", &[], Duration::from_secs(1)).await.is_err()); |
| 158 | 1× | assert!(client("http://127.0.0.1:11434/", &["http://127.0.0.1:11434".into()], Duration::from_secs(1)).await.is_ok()); |
| 159 | 1× | } |
| 160 | | } |