aboutsummaryrefslogtreecommitdiffstats
path: root/src/net.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-04 01:29:57 +0100
committersalmonik <salmonik@tuta.io>2026-11-04 01:29:57 +0100
commit4b2b4af606237def78273a42e881ae9f971cb505 (patch)
tree47735062971b7dcddfbe3ad385ccbf2570fa6245 /src/net.rs
parentdocker: install ImageMagick for image compress/convert (diff)
downloadsenator-sperma-4b2b4af606237def78273a42e881ae9f971cb505.tar.gz
senator-sperma-4b2b4af606237def78273a42e881ae9f971cb505.zip
net: block private and link-local URLs
Diffstat (limited to '')
-rw-r--r--src/net.rs130
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
12use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
13
14use 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.
19pub 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
52fn 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
72fn 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)]
93mod 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}