//! Network egress checks for user-supplied URLs. //! //! The bot can be asked to fetch arbitrary URLs (`/download`, `/music`, cron //! audio). Without a check, that is a server-side request forgery primitive: //! loopback services, Docker host gateways, private LANs, and cloud metadata //! (`169.254.169.254`) are all reachable from the container. //! //! This is a best-effort DNS check. It raises the bar substantially, but a //! determined attacker can still use DNS rebinding between this check and the //! actual request; combine it with network egress rules for full protection. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use crate::error::{Error, Result}; /// Validate that `url` is an `http(s)` URL whose host resolves only to public /// addresses. Call this before handing a user-supplied URL to reqwest, ffmpeg, /// or yt-dlp. pub async fn ensure_public_http_url(url: &str) -> Result<()> { let parsed = reqwest::Url::parse(url) .map_err(|e| Error::Custom(format!("Invalid URL: {e}")))?; if !matches!(parsed.scheme(), "http" | "https") { return Err(Error::Custom("Only http:// and https:// URLs are allowed".into())); } let host = parsed .host_str() .ok_or_else(|| Error::Custom("URL has no host".into()))?; // `host_str` keeps IPv6 brackets. let host = host.trim_start_matches('[').trim_end_matches(']'); let port = parsed.port_or_known_default().unwrap_or(80); let mut resolved = false; let addresses = tokio::net::lookup_host((host, port)) .await .map_err(|e| Error::Custom(format!("Could not resolve {host}: {e}")))?; for address in addresses { resolved = true; if !is_public_ip(address.ip()) { return Err(Error::Custom(format!( "Refusing to fetch a private or local address ({})", address.ip() ))); } } if !resolved { return Err(Error::Custom(format!("Could not resolve {host}"))); } Ok(()) } fn is_public_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => is_public_v4(v4), IpAddr::V6(v6) => { if let Some(v4) = v6.to_ipv4_mapped() { return is_public_v4(v4); } !(v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() // fc00::/7 unique-local || (v6.segments()[0] & 0xfe00) == 0xfc00 // fe80::/10 link-local || (v6.segments()[0] & 0xffc0) == 0xfe80 // 2001:db8::/32 documentation || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8)) } } } fn is_public_v4(ip: Ipv4Addr) -> bool { let octets = ip.octets(); !(ip.is_private() || ip.is_loopback() || ip.is_link_local() || ip.is_broadcast() || ip.is_documentation() || ip.is_unspecified() || ip.is_multicast() // 0.0.0.0/8, 240.0.0.0/4 and other non-routable special ranges. || octets[0] == 0 || octets[0] >= 240 // 100.64.0.0/10 carrier-grade NAT || (octets[0] == 100 && (64..=127).contains(&octets[1])) // 192.0.0.0/24 IETF protocol assignments || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) // 198.18.0.0/15 benchmarking || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19))) } #[cfg(test)] mod tests { use super::*; #[test] fn private_and_special_ranges_are_blocked() { for ip in [ "127.0.0.1", "10.0.0.1", "172.16.5.4", "192.168.1.1", "169.254.169.254", "0.0.0.0", "100.64.0.1", "198.18.0.1", "224.0.0.1", "255.255.255.255", "::1", "fe80::1", "fc00::1", "::ffff:127.0.0.1", ] { assert!( !is_public_ip(ip.parse().unwrap()), "{ip} should not be considered public" ); } } #[test] fn public_ranges_are_allowed() { for ip in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] { assert!( is_public_ip(ip.parse().unwrap()), "{ip} should be public" ); } } }