aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-04 01:06:38 +0100
committersalmonik <salmonik@tuta.io>2026-11-04 01:06:38 +0100
commit64afa0fd8c863deb6a1568428dfaf2ce67ef9085 (patch)
tree14ff21f7bcb30d8a5234a2d535e0977d0d5f5a04
parentsay: do not abort if the ephemeral ack is already gone (diff)
downloadsenator-sperma-64afa0fd8c863deb6a1568428dfaf2ce67ef9085.tar.gz
senator-sperma-64afa0fd8c863deb6a1568428dfaf2ce67ef9085.zip
copyparty: never send the password to foreign hosts
-rw-r--r--src/commands/utilities.rs8
-rw-r--r--src/copyparty/mod.rs139
-rw-r--r--src/tasks/mod.rs29
-rw-r--r--src/voice/mod.rs17
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;
4use super::autocomplete::any_file; 4use super::autocomplete::any_file;
5use crate::music::sanitize_yt_url; 5use crate::music::sanitize_yt_url;
6use crate::Data; 6use crate::Data;
7use crate::copyparty::{html_decode, percent_decode}; 7use crate::copyparty::{html_decode, percent_decode, redact_url};
8use crate::error::{Error, Result}; 8use crate::error::{Error, Result};
9use crate::lang; 9use 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 @@
1use std::time::Duration; 1use std::time::Duration;
2 2
3use reqwest::Client; 3use reqwest::{Client, Url};
4use serde::Deserialize; 4use serde::Deserialize;
5use tracing::warn; 5use 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.
105pub 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)]
130mod 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;
16use tokio::sync::Mutex; 16use tokio::sync::Mutex;
17use tracing::{info, warn}; 17use tracing::{info, warn};
18 18
19use crate::copyparty::redact_url;
19use crate::error::{Error, Result}; 20use crate::error::{Error, Result};
20use crate::lang; 21use crate::lang;
21use crate::recording::{Recorder, RecorderEventHandler}; 22use 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 }