From 4b2b4af606237def78273a42e881ae9f971cb505 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 4 Nov 2026 01:29:57 +0100 Subject: net: block private and link-local URLs --- src/commands/cron.rs | 3 ++ src/commands/music.rs | 4 ++ src/commands/utilities.rs | 5 ++ src/copyparty/mod.rs | 2 +- src/main.rs | 1 + src/net.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++ src/tasks/mod.rs | 6 +++ 7 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/net.rs (limited to 'src') 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( }; let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; + if !ctx.data().copyparty.is_own_url(&full_url) { + crate::net::ensure_public_http_url(&full_url).await?; + } database::add_cron_job( &ctx.data().db_pool, 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( let url = name.clone(); let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url)); + if !ctx.data().copyparty.is_own_url(&url) { + crate::net::ensure_public_http_url(&url).await?; + } + // yt-dlp platforms → download; everything else (radio, HLS, // playlists, Copyparty, direct media) → ffmpeg stream. 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( fformat: Option<&str>, filename_user: Option<&str>, ) -> Result<()> { + // Copyparty may legitimately live on localhost/LAN; every other host must + // be public. This blocks metadata endpoints and internal services. + if !ctx.data().copyparty.is_own_url(url) { + crate::net::ensure_public_http_url(url).await?; + } let fmt = fformat.unwrap_or("mp4"); let is_audio = matches!(fmt, "mp3" | "wav" | "m4a"); 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 { } /// Is `url` the same origin as the configured Copyparty base URL? - fn is_own_url(&self, url: &str) -> bool { + pub fn is_own_url(&self, url: &str) -> bool { let (Ok(candidate), Ok(base)) = (Url::parse(url), Url::parse(&self.base_url)) else { return false; }; 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; mod events; mod lang; mod music; +mod net; mod openrouter; mod recording; 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 @@ +//! 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" + ); + } + } +} 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) { // job may point at an arbitrary third-party URL and must never receive // the password. let url = ctx.copyparty.build_url_for(audio_url); + if !ctx.copyparty.is_own_url(&url) + && let Err(e) = crate::net::ensure_public_http_url(&url).await + { + error!("Cron audio job {} refused URL: {e}", job.id); + return; + } info!( "Cron audio job {} playing: {}", job.id, -- cgit v1.2.3