aboutsummaryrefslogtreecommitdiffstats
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
parentdocker: install ImageMagick for image compress/convert (diff)
downloadsenator-sperma-4b2b4af606237def78273a42e881ae9f971cb505.tar.gz
senator-sperma-4b2b4af606237def78273a42e881ae9f971cb505.zip
net: block private and link-local URLs
-rw-r--r--Cargo.toml1
-rw-r--r--src/commands/cron.rs3
-rw-r--r--src/commands/music.rs4
-rw-r--r--src/commands/utilities.rs5
-rw-r--r--src/copyparty/mod.rs2
-rw-r--r--src/main.rs1
-rw-r--r--src/net.rs130
-rw-r--r--src/tasks/mod.rs6
8 files changed, 151 insertions, 1 deletions
diff --git a/Cargo.toml b/Cargo.toml
index b839b89..1d1b085 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,6 +20,7 @@ tokio = { version = "1.52.3", features = [
20 "process", 20 "process",
21 "time", 21 "time",
22 "io-util", 22 "io-util",
23 "net",
23] } 24] }
24 25
25# Database 26# Database
diff --git a/src/commands/cron.rs b/src/commands/cron.rs
index ebaa96d..301ae3d 100644
--- a/src/commands/cron.rs
+++ b/src/commands/cron.rs
@@ -45,6 +45,9 @@ pub async fn dodaj_audio(
45 }; 45 };
46 46
47 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; 47 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
48 if !ctx.data().copyparty.is_own_url(&full_url) {
49 crate::net::ensure_public_http_url(&full_url).await?;
50 }
48 database::add_cron_job( 51 database::add_cron_job(
49 &ctx.data().db_pool, 52 &ctx.data().db_pool,
50 guild_id.get() as i64, 53 guild_id.get() as i64,
diff --git a/src/commands/music.rs b/src/commands/music.rs
index 245eb88..baa91b1 100644
--- a/src/commands/music.rs
+++ b/src/commands/music.rs
@@ -121,6 +121,10 @@ async fn resolve_source(
121 let url = name.clone(); 121 let url = name.clone();
122 let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url)); 122 let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url));
123 123
124 if !ctx.data().copyparty.is_own_url(&url) {
125 crate::net::ensure_public_http_url(&url).await?;
126 }
127
124 // yt-dlp platforms → download; everything else (radio, HLS, 128 // yt-dlp platforms → download; everything else (radio, HLS,
125 // playlists, Copyparty, direct media) → ffmpeg stream. 129 // playlists, Copyparty, direct media) → ffmpeg stream.
126 let kind = classify_url(&url); 130 let kind = classify_url(&url);
diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs
index c26fd0e..7a2e65d 100644
--- a/src/commands/utilities.rs
+++ b/src/commands/utilities.rs
@@ -496,6 +496,11 @@ async fn run_download(
496 fformat: Option<&str>, 496 fformat: Option<&str>,
497 filename_user: Option<&str>, 497 filename_user: Option<&str>,
498) -> Result<()> { 498) -> Result<()> {
499 // Copyparty may legitimately live on localhost/LAN; every other host must
500 // be public. This blocks metadata endpoints and internal services.
501 if !ctx.data().copyparty.is_own_url(url) {
502 crate::net::ensure_public_http_url(url).await?;
503 }
499 let fmt = fformat.unwrap_or("mp4"); 504 let fmt = fformat.unwrap_or("mp4");
500 let is_audio = matches!(fmt, "mp3" | "wav" | "m4a"); 505 let is_audio = matches!(fmt, "mp3" | "wav" | "m4a");
501 let dl_dir = format!("{}/dl_{}", crate::config::TEMP_PATH, uuid::Uuid::new_v4()); 506 let dl_dir = format!("{}/dl_{}", crate::config::TEMP_PATH, uuid::Uuid::new_v4());
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs
index dfc43dc..98db471 100644
--- a/src/copyparty/mod.rs
+++ b/src/copyparty/mod.rs
@@ -295,7 +295,7 @@ impl CopypartyClient {
295 } 295 }
296 296
297 /// Is `url` the same origin as the configured Copyparty base URL? 297 /// Is `url` the same origin as the configured Copyparty base URL?
298 fn is_own_url(&self, url: &str) -> bool { 298 pub fn is_own_url(&self, url: &str) -> bool {
299 let (Ok(candidate), Ok(base)) = (Url::parse(url), Url::parse(&self.base_url)) else { 299 let (Ok(candidate), Ok(base)) = (Url::parse(url), Url::parse(&self.base_url)) else {
300 return false; 300 return false;
301 }; 301 };
diff --git a/src/main.rs b/src/main.rs
index 132e93b..3483c9a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,6 +14,7 @@ mod error;
14mod events; 14mod events;
15mod lang; 15mod lang;
16mod music; 16mod music;
17mod net;
17mod openrouter; 18mod openrouter;
18mod recording; 19mod recording;
19mod tasks; 20mod tasks;
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}
diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs
index 362db80..c895a1b 100644
--- a/src/tasks/mod.rs
+++ b/src/tasks/mod.rs
@@ -122,6 +122,12 @@ async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) {
122 // job may point at an arbitrary third-party URL and must never receive 122 // job may point at an arbitrary third-party URL and must never receive
123 // the password. 123 // the password.
124 let url = ctx.copyparty.build_url_for(audio_url); 124 let url = ctx.copyparty.build_url_for(audio_url);
125 if !ctx.copyparty.is_own_url(&url)
126 && let Err(e) = crate::net::ensure_public_http_url(&url).await
127 {
128 error!("Cron audio job {} refused URL: {e}", job.id);
129 return;
130 }
125 info!( 131 info!(
126 "Cron audio job {} playing: {}", 132 "Cron audio job {} playing: {}",
127 job.id, 133 job.id,