diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-04 01:06:38 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-04 01:06:38 +0100 |
| commit | 64afa0fd8c863deb6a1568428dfaf2ce67ef9085 (patch) | |
| tree | 14ff21f7bcb30d8a5234a2d535e0977d0d5f5a04 | |
| parent | say: do not abort if the ephemeral ack is already gone (diff) | |
| download | senator-sperma-64afa0fd8c863deb6a1568428dfaf2ce67ef9085.tar.gz senator-sperma-64afa0fd8c863deb6a1568428dfaf2ce67ef9085.zip | |
copyparty: never send the password to foreign hosts
| -rw-r--r-- | src/commands/utilities.rs | 8 | ||||
| -rw-r--r-- | src/copyparty/mod.rs | 139 | ||||
| -rw-r--r-- | src/tasks/mod.rs | 29 | ||||
| -rw-r--r-- | src/voice/mod.rs | 17 |
4 files changed, 165 insertions, 28 deletions
diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs index b77f44d..33d732f 100644 --- a/src/commands/utilities.rs +++ b/src/commands/utilities.rs | |||
| @@ -4,7 +4,7 @@ use tracing::warn; | |||
| 4 | use super::autocomplete::any_file; | 4 | use super::autocomplete::any_file; |
| 5 | use crate::music::sanitize_yt_url; | 5 | use crate::music::sanitize_yt_url; |
| 6 | use crate::Data; | 6 | use crate::Data; |
| 7 | use crate::copyparty::{html_decode, percent_decode}; | 7 | use crate::copyparty::{html_decode, percent_decode, redact_url}; |
| 8 | use crate::error::{Error, Result}; | 8 | use crate::error::{Error, Result}; |
| 9 | use crate::lang; | 9 | use crate::lang; |
| 10 | 10 | ||
| @@ -502,7 +502,11 @@ async fn run_download( | |||
| 502 | let stderr = String::from_utf8_lossy(&output.stderr); | 502 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 503 | // Keep the full yt-dlp trace in the logs; the user only sees the | 503 | // Keep the full yt-dlp trace in the logs; the user only sees the |
| 504 | // short `fmt_download_failed` (translated) message. | 504 | // short `fmt_download_failed` (translated) message. |
| 505 | warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end()); | 505 | warn!( |
| 506 | url = %redact_url(url), | ||
| 507 | "yt-dlp failed:\n{}", | ||
| 508 | stderr.trim_end() | ||
| 509 | ); | ||
| 506 | let _ = tokio::fs::remove_dir_all(&dl_dir).await; | 510 | let _ = tokio::fs::remove_dir_all(&dl_dir).await; |
| 507 | ctx.say(lang::fmt_download_failed(&stderr)).await?; | 511 | ctx.say(lang::fmt_download_failed(&stderr)).await?; |
| 508 | return Ok(()); | 512 | return Ok(()); |
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs index d3620b2..1f075a0 100644 --- a/src/copyparty/mod.rs +++ b/src/copyparty/mod.rs | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | use std::time::Duration; | 1 | use std::time::Duration; |
| 2 | 2 | ||
| 3 | use reqwest::Client; | 3 | use reqwest::{Client, Url}; |
| 4 | use serde::Deserialize; | 4 | use serde::Deserialize; |
| 5 | use tracing::warn; | 5 | use tracing::warn; |
| 6 | 6 | ||
| @@ -100,6 +100,88 @@ fn hex_nibble(b: u8) -> Option<u8> { | |||
| 100 | } | 100 | } |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | /// Replace Copyparty's `pw` query parameter with `***` for logs and errors. | ||
| 104 | /// Leaves non-secret URLs untouched. | ||
| 105 | pub fn redact_url(url: &str) -> String { | ||
| 106 | let Ok(mut parsed) = Url::parse(url) else { | ||
| 107 | return url.to_string(); | ||
| 108 | }; | ||
| 109 | let mut redacted = false; | ||
| 110 | let pairs: Vec<(String, String)> = parsed | ||
| 111 | .query_pairs() | ||
| 112 | .map(|(key, value)| { | ||
| 113 | let value = if key.eq_ignore_ascii_case("pw") { | ||
| 114 | redacted = true; | ||
| 115 | "***".to_string() | ||
| 116 | } else { | ||
| 117 | value.into_owned() | ||
| 118 | }; | ||
| 119 | (key.into_owned(), value) | ||
| 120 | }) | ||
| 121 | .collect(); | ||
| 122 | if redacted { | ||
| 123 | parsed.query_pairs_mut().clear(); | ||
| 124 | parsed.query_pairs_mut().extend_pairs(pairs); | ||
| 125 | } | ||
| 126 | parsed.to_string() | ||
| 127 | } | ||
| 128 | |||
| 129 | #[cfg(test)] | ||
| 130 | mod tests { | ||
| 131 | use super::*; | ||
| 132 | |||
| 133 | fn client(password: Option<&str>) -> CopypartyClient { | ||
| 134 | CopypartyClient::new( | ||
| 135 | "http://localhost:3939".into(), | ||
| 136 | "http://localhost:3939/uploads".into(), | ||
| 137 | password.map(str::to_string), | ||
| 138 | ) | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn authenticates_only_own_host() { | ||
| 143 | let c = client(Some("s3cret")); | ||
| 144 | assert!(c.authenticate_url("http://localhost:3939/a.mp3").contains("pw=s3cret")); | ||
| 145 | assert_eq!( | ||
| 146 | c.authenticate_url("https://evil.example/a.mp3"), | ||
| 147 | "https://evil.example/a.mp3" | ||
| 148 | ); | ||
| 149 | // Same host, different scheme/port is not our Copyparty. | ||
| 150 | assert_eq!( | ||
| 151 | c.authenticate_url("https://localhost:3939/a.mp3"), | ||
| 152 | "https://localhost:3939/a.mp3" | ||
| 153 | ); | ||
| 154 | } | ||
| 155 | |||
| 156 | #[test] | ||
| 157 | fn encodes_password_and_keeps_existing_query() { | ||
| 158 | let c = client(Some("p&w/word")); | ||
| 159 | let url = c.authenticate_url("http://localhost:3939/a.mp3?download=1"); | ||
| 160 | assert!(url.contains("download=1"), "{url}"); | ||
| 161 | assert!(url.contains("pw=p%26w%2Fword"), "{url}"); | ||
| 162 | assert_eq!(url.matches("pw=").count(), 1, "{url}"); | ||
| 163 | } | ||
| 164 | |||
| 165 | #[test] | ||
| 166 | fn build_url_for_handles_paths_and_urls() { | ||
| 167 | let c = client(Some("pw")); | ||
| 168 | assert!(c.build_url_for("/music/a.mp3").contains("pw=pw")); | ||
| 169 | assert_eq!( | ||
| 170 | c.build_url_for("https://evil.example/a.mp3"), | ||
| 171 | "https://evil.example/a.mp3" | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | |||
| 175 | #[test] | ||
| 176 | fn redact_hides_password() { | ||
| 177 | assert_eq!( | ||
| 178 | redact_url("http://localhost:3939/a.mp3?pw=secret&x=1"), | ||
| 179 | "http://localhost:3939/a.mp3?pw=***&x=1" | ||
| 180 | ); | ||
| 181 | assert_eq!(redact_url("https://example.com/a"), "https://example.com/a"); | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 103 | /// A search result from copyparty's `/?srch` API. | 185 | /// A search result from copyparty's `/?srch` API. |
| 104 | /// | 186 | /// |
| 105 | /// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`). | 187 | /// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`). |
| @@ -159,12 +241,7 @@ impl CopypartyClient { | |||
| 159 | /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`) | 241 | /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`) |
| 160 | /// or relative (`music/foo.mp3`). | 242 | /// or relative (`music/foo.mp3`). |
| 161 | pub fn build_url(&self, path: &str) -> String { | 243 | pub fn build_url(&self, path: &str) -> String { |
| 162 | let prefixed = self.build_url_no_pw(path); | 244 | self.authenticate_url(&self.build_url_no_pw(path)) |
| 163 | if let Some(pw) = &self.password { | ||
| 164 | format!("{prefixed}?pw={pw}") | ||
| 165 | } else { | ||
| 166 | prefixed | ||
| 167 | } | ||
| 168 | } | 245 | } |
| 169 | 246 | ||
| 170 | /// Build full URL without the password parameter. | 247 | /// Build full URL without the password parameter. |
| @@ -176,6 +253,54 @@ impl CopypartyClient { | |||
| 176 | } | 253 | } |
| 177 | } | 254 | } |
| 178 | 255 | ||
| 256 | /// Add the Copyparty password to a URL **only when it points at our own | ||
| 257 | /// Copyparty host**. This is the safe variant of `build_url` for URLs that | ||
| 258 | /// may have come from a user (cron audio, playlists, redirects): a foreign | ||
| 259 | /// host must never receive `?pw=...`. | ||
| 260 | pub fn authenticate_url(&self, url: &str) -> String { | ||
| 261 | let Some(password) = &self.password else { | ||
| 262 | return url.to_string(); | ||
| 263 | }; | ||
| 264 | if !self.is_own_url(url) { | ||
| 265 | return url.to_string(); | ||
| 266 | } | ||
| 267 | let Ok(mut parsed) = Url::parse(url) else { | ||
| 268 | return url.to_string(); | ||
| 269 | }; | ||
| 270 | // Replace any existing `pw` parameter rather than appending a second | ||
| 271 | // one (also handles URLs that already carry a query string). | ||
| 272 | let pairs: Vec<(String, String)> = parsed | ||
| 273 | .query_pairs() | ||
| 274 | .filter(|(key, _)| key != "pw") | ||
| 275 | .map(|(key, value)| (key.into_owned(), value.into_owned())) | ||
| 276 | .collect(); | ||
| 277 | parsed.query_pairs_mut().clear(); | ||
| 278 | parsed.query_pairs_mut().extend_pairs(pairs); | ||
| 279 | parsed.query_pairs_mut().append_pair("pw", password); | ||
| 280 | parsed.to_string() | ||
| 281 | } | ||
| 282 | |||
| 283 | /// Accept either a path or a full URL: paths are resolved against the | ||
| 284 | /// configured Copyparty base URL, absolute URLs are authenticated only | ||
| 285 | /// when they belong to Copyparty. | ||
| 286 | pub fn build_url_for(&self, path_or_url: &str) -> String { | ||
| 287 | if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { | ||
| 288 | self.authenticate_url(path_or_url) | ||
| 289 | } else { | ||
| 290 | self.build_url(path_or_url) | ||
| 291 | } | ||
| 292 | } | ||
| 293 | |||
| 294 | /// Is `url` the same origin as the configured Copyparty base URL? | ||
| 295 | fn is_own_url(&self, url: &str) -> bool { | ||
| 296 | let (Ok(candidate), Ok(base)) = (Url::parse(url), Url::parse(&self.base_url)) else { | ||
| 297 | return false; | ||
| 298 | }; | ||
| 299 | candidate.scheme() == base.scheme() | ||
| 300 | && candidate.host_str() == base.host_str() | ||
| 301 | && candidate.port_or_known_default() == base.port_or_known_default() | ||
| 302 | } | ||
| 303 | |||
| 179 | /// Search copyparty via the jPOST `/?srch` endpoint. | 304 | /// Search copyparty via the jPOST `/?srch` endpoint. |
| 180 | /// | 305 | /// |
| 181 | /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python | 306 | /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python |
diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index f0585ef..b492f61 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs | |||
| @@ -125,19 +125,15 @@ async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) { | |||
| 125 | 125 | ||
| 126 | let channel_id = serenity::ChannelId::new(job.channel_id as u64); | 126 | let channel_id = serenity::ChannelId::new(job.channel_id as u64); |
| 127 | 127 | ||
| 128 | // Build the full URL with password | 128 | // Authenticate only when the URL belongs to our Copyparty host; a cron |
| 129 | let url = if audio_url.starts_with("http://") || audio_url.starts_with("https://") { | 129 | // job may point at an arbitrary third-party URL and must never receive |
| 130 | // Already a full URL (stored without password), append it now | 130 | // the password. |
| 131 | if let Some(pw) = &ctx.copyparty.password { | 131 | let url = ctx.copyparty.build_url_for(audio_url); |
| 132 | format!("{audio_url}?pw={pw}") | 132 | info!( |
| 133 | } else { | 133 | "Cron audio job {} playing: {}", |
| 134 | audio_url.clone() | 134 | job.id, |
| 135 | } | 135 | crate::copyparty::redact_url(&url) |
| 136 | } else { | 136 | ); |
| 137 | // Legacy relative path — build full URL with password | ||
| 138 | ctx.copyparty.build_url(audio_url) | ||
| 139 | }; | ||
| 140 | info!("Cron audio job {} playing: {url}", job.id); | ||
| 141 | let channel = match channel_id.to_channel(&ctx.http).await { | 137 | let channel = match channel_id.to_channel(&ctx.http).await { |
| 142 | Ok(serenity::Channel::Guild(ch)) => ch, | 138 | Ok(serenity::Channel::Guild(ch)) => ch, |
| 143 | other => { | 139 | other => { |
| @@ -175,8 +171,11 @@ async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) { | |||
| 175 | } | 171 | } |
| 176 | 172 | ||
| 177 | info!( | 173 | info!( |
| 178 | "Cron audio job {} playing '{}' (url: {url}) in guild {}", | 174 | "Cron audio job {} playing '{}' (url: {}) in guild {}", |
| 179 | job.id, audio_url, channel.guild_id | 175 | job.id, |
| 176 | audio_url, | ||
| 177 | crate::copyparty::redact_url(&url), | ||
| 178 | channel.guild_id | ||
| 180 | ); | 179 | ); |
| 181 | } | 180 | } |
| 182 | 181 | ||
diff --git a/src/voice/mod.rs b/src/voice/mod.rs index fcb0b07..5a89f93 100644 --- a/src/voice/mod.rs +++ b/src/voice/mod.rs | |||
| @@ -16,6 +16,7 @@ use symphonia::core::meta::MetadataOptions; | |||
| 16 | use tokio::sync::Mutex; | 16 | use tokio::sync::Mutex; |
| 17 | use tracing::{info, warn}; | 17 | use tracing::{info, warn}; |
| 18 | 18 | ||
| 19 | use crate::copyparty::redact_url; | ||
| 19 | use crate::error::{Error, Result}; | 20 | use crate::error::{Error, Result}; |
| 20 | use crate::lang; | 21 | use crate::lang; |
| 21 | use crate::recording::{Recorder, RecorderEventHandler}; | 22 | use crate::recording::{Recorder, RecorderEventHandler}; |
| @@ -241,7 +242,11 @@ pub async fn ytdlp_download(url: &str) -> Result<Vec<u8>> { | |||
| 241 | let stderr = String::from_utf8_lossy(&output.stderr); | 242 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 242 | // Keep the full yt-dlp trace in the logs for debugging — the user | 243 | // Keep the full yt-dlp trace in the logs for debugging — the user |
| 243 | // only ever sees the short, translated `fmt_ytdlp_stderr` message. | 244 | // only ever sees the short, translated `fmt_ytdlp_stderr` message. |
| 244 | warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end()); | 245 | warn!( |
| 246 | url = %redact_url(url), | ||
| 247 | "yt-dlp failed:\n{}", | ||
| 248 | stderr.trim_end() | ||
| 249 | ); | ||
| 245 | return Err(Error::Custom(lang::fmt_ytdlp_stderr(&stderr))); | 250 | return Err(Error::Custom(lang::fmt_ytdlp_stderr(&stderr))); |
| 246 | } | 251 | } |
| 247 | 252 | ||
| @@ -256,11 +261,11 @@ pub async fn download_url(client: &reqwest::Client, url: &str) -> Result<Vec<u8> | |||
| 256 | .get(url) | 261 | .get(url) |
| 257 | .send() | 262 | .send() |
| 258 | .await | 263 | .await |
| 259 | .map_err(|e| Error::Custom(format!("HTTP GET failed for {url}: {e}")))?; | 264 | .map_err(|e| Error::Custom(format!("HTTP GET failed for {}: {e}", redact_url(url))))?; |
| 260 | let bytes = resp | 265 | let bytes = resp |
| 261 | .bytes() | 266 | .bytes() |
| 262 | .await | 267 | .await |
| 263 | .map_err(|e| Error::Custom(format!("Failed to read body for {url}: {e}")))?; | 268 | .map_err(|e| Error::Custom(format!("Failed to read body for {}: {e}", redact_url(url))))?; |
| 264 | Ok(bytes.to_vec()) | 269 | Ok(bytes.to_vec()) |
| 265 | } | 270 | } |
| 266 | 271 | ||
| @@ -398,7 +403,11 @@ pub async fn ffmpeg_stream_input(url: &str) -> Result<songbird::input::Input> { | |||
| 398 | let _ = reader.read_to_end(&mut buf).await; | 403 | let _ = reader.read_to_end(&mut buf).await; |
| 399 | let text = String::from_utf8_lossy(&buf); | 404 | let text = String::from_utf8_lossy(&buf); |
| 400 | if !text.is_empty() { | 405 | if !text.is_empty() { |
| 401 | warn!(url = %url_owned, "ffmpeg stderr: {}", text.trim_end()); | 406 | warn!( |
| 407 | url = %redact_url(&url_owned), | ||
| 408 | "ffmpeg stderr: {}", | ||
| 409 | text.trim_end() | ||
| 410 | ); | ||
| 402 | } | 411 | } |
| 403 | }); | 412 | }); |
| 404 | } | 413 | } |
