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 | |
| 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
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | src/commands/cron.rs | 3 | ||||
| -rw-r--r-- | src/commands/music.rs | 4 | ||||
| -rw-r--r-- | src/commands/utilities.rs | 5 | ||||
| -rw-r--r-- | src/copyparty/mod.rs | 2 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/net.rs | 130 | ||||
| -rw-r--r-- | src/tasks/mod.rs | 6 |
8 files changed, 151 insertions, 1 deletions
| @@ -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; | |||
| 14 | mod events; | 14 | mod events; |
| 15 | mod lang; | 15 | mod lang; |
| 16 | mod music; | 16 | mod music; |
| 17 | mod net; | ||
| 17 | mod openrouter; | 18 | mod openrouter; |
| 18 | mod recording; | 19 | mod recording; |
| 19 | mod tasks; | 20 | mod 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 | |||
| 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 | } | ||
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, |
