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 /src/copyparty/mod.rs | |
| 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
Diffstat (limited to '')
| -rw-r--r-- | src/copyparty/mod.rs | 139 |
1 files changed, 132 insertions, 7 deletions
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 |
