diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-04 01:29:57 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-04 01:29:57 +0100 |
| commit | 4b2b4af606237def78273a42e881ae9f971cb505 (patch) | |
| tree | 47735062971b7dcddfbe3ad385ccbf2570fa6245 /src/net.rs | |
| parent | docker: install ImageMagick for image compress/convert (diff) | |
| download | senator-sperma-4b2b4af606237def78273a42e881ae9f971cb505.tar.gz senator-sperma-4b2b4af606237def78273a42e881ae9f971cb505.zip | |
net: block private and link-local URLs
Diffstat (limited to '')
| -rw-r--r-- | src/net.rs | 130 |
1 files changed, 130 insertions, 0 deletions
diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..dc9b7f2 --- /dev/null +++ b/src/net.rs | |||
| @@ -0,0 +1,130 @@ | |||
| 1 | //! Network egress checks for user-supplied URLs. | ||
| 2 | //! | ||
| 3 | //! The bot can be asked to fetch arbitrary URLs (`/download`, `/music`, cron | ||
| 4 | //! audio). Without a check, that is a server-side request forgery primitive: | ||
| 5 | //! loopback services, Docker host gateways, private LANs, and cloud metadata | ||
| 6 | //! (`169.254.169.254`) are all reachable from the container. | ||
| 7 | //! | ||
| 8 | //! This is a best-effort DNS check. It raises the bar substantially, but a | ||
| 9 | //! determined attacker can still use DNS rebinding between this check and the | ||
| 10 | //! actual request; combine it with network egress rules for full protection. | ||
| 11 | |||
| 12 | use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; | ||
| 13 | |||
| 14 | use crate::error::{Error, Result}; | ||
| 15 | |||
| 16 | /// Validate that `url` is an `http(s)` URL whose host resolves only to public | ||
| 17 | /// addresses. Call this before handing a user-supplied URL to reqwest, ffmpeg, | ||
| 18 | /// or yt-dlp. | ||
| 19 | pub async fn ensure_public_http_url(url: &str) -> Result<()> { | ||
| 20 | let parsed = reqwest::Url::parse(url) | ||
| 21 | .map_err(|e| Error::Custom(format!("Invalid URL: {e}")))?; | ||
| 22 | if !matches!(parsed.scheme(), "http" | "https") { | ||
| 23 | return Err(Error::Custom("Only http:// and https:// URLs are allowed".into())); | ||
| 24 | } | ||
| 25 | |||
| 26 | let host = parsed | ||
| 27 | .host_str() | ||
| 28 | .ok_or_else(|| Error::Custom("URL has no host".into()))?; | ||
| 29 | // `host_str` keeps IPv6 brackets. | ||
| 30 | let host = host.trim_start_matches('[').trim_end_matches(']'); | ||
| 31 | let port = parsed.port_or_known_default().unwrap_or(80); | ||
| 32 | |||
| 33 | let mut resolved = false; | ||
| 34 | let addresses = tokio::net::lookup_host((host, port)) | ||
| 35 | .await | ||
| 36 | .map_err(|e| Error::Custom(format!("Could not resolve {host}: {e}")))?; | ||
| 37 | for address in addresses { | ||
| 38 | resolved = true; | ||
| 39 | if !is_public_ip(address.ip()) { | ||
| 40 | return Err(Error::Custom(format!( | ||
| 41 | "Refusing to fetch a private or local address ({})", | ||
| 42 | address.ip() | ||
| 43 | ))); | ||
| 44 | } | ||
| 45 | } | ||
| 46 | if !resolved { | ||
| 47 | return Err(Error::Custom(format!("Could not resolve {host}"))); | ||
| 48 | } | ||
| 49 | Ok(()) | ||
| 50 | } | ||
| 51 | |||
| 52 | fn is_public_ip(ip: IpAddr) -> bool { | ||
| 53 | match ip { | ||
| 54 | IpAddr::V4(v4) => is_public_v4(v4), | ||
| 55 | IpAddr::V6(v6) => { | ||
| 56 | if let Some(v4) = v6.to_ipv4_mapped() { | ||
| 57 | return is_public_v4(v4); | ||
| 58 | } | ||
| 59 | !(v6.is_loopback() | ||
| 60 | || v6.is_unspecified() | ||
| 61 | || v6.is_multicast() | ||
| 62 | // fc00::/7 unique-local | ||
| 63 | || (v6.segments()[0] & 0xfe00) == 0xfc00 | ||
| 64 | // fe80::/10 link-local | ||
| 65 | || (v6.segments()[0] & 0xffc0) == 0xfe80 | ||
| 66 | // 2001:db8::/32 documentation | ||
| 67 | || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8)) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | fn is_public_v4(ip: Ipv4Addr) -> bool { | ||
| 73 | let octets = ip.octets(); | ||
| 74 | !(ip.is_private() | ||
| 75 | || ip.is_loopback() | ||
| 76 | || ip.is_link_local() | ||
| 77 | || ip.is_broadcast() | ||
| 78 | || ip.is_documentation() | ||
| 79 | || ip.is_unspecified() | ||
| 80 | || ip.is_multicast() | ||
| 81 | // 0.0.0.0/8, 240.0.0.0/4 and other non-routable special ranges. | ||
| 82 | || octets[0] == 0 | ||
| 83 | || octets[0] >= 240 | ||
| 84 | // 100.64.0.0/10 carrier-grade NAT | ||
| 85 | || (octets[0] == 100 && (64..=127).contains(&octets[1])) | ||
| 86 | // 192.0.0.0/24 IETF protocol assignments | ||
| 87 | || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) | ||
| 88 | // 198.18.0.0/15 benchmarking | ||
| 89 | || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19))) | ||
| 90 | } | ||
| 91 | |||
| 92 | #[cfg(test)] | ||
| 93 | mod tests { | ||
| 94 | use super::*; | ||
| 95 | |||
| 96 | #[test] | ||
| 97 | fn private_and_special_ranges_are_blocked() { | ||
| 98 | for ip in [ | ||
| 99 | "127.0.0.1", | ||
| 100 | "10.0.0.1", | ||
| 101 | "172.16.5.4", | ||
| 102 | "192.168.1.1", | ||
| 103 | "169.254.169.254", | ||
| 104 | "0.0.0.0", | ||
| 105 | "100.64.0.1", | ||
| 106 | "198.18.0.1", | ||
| 107 | "224.0.0.1", | ||
| 108 | "255.255.255.255", | ||
| 109 | "::1", | ||
| 110 | "fe80::1", | ||
| 111 | "fc00::1", | ||
| 112 | "::ffff:127.0.0.1", | ||
| 113 | ] { | ||
| 114 | assert!( | ||
| 115 | !is_public_ip(ip.parse().unwrap()), | ||
| 116 | "{ip} should not be considered public" | ||
| 117 | ); | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | #[test] | ||
| 122 | fn public_ranges_are_allowed() { | ||
| 123 | for ip in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] { | ||
| 124 | assert!( | ||
| 125 | is_public_ip(ip.parse().unwrap()), | ||
| 126 | "{ip} should be public" | ||
| 127 | ); | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
