diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index bcf541a..8e40f20 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351). - A new install's `admin` gets a random password, written to `admin-password` beside config.toml and readable by iPX's user alone, or the one in `IPX_ADMIN_PASSWORD`. Every install began as admin / ipodderx, a password published in the README, and the log printed it (#321). - A show kept from a private OPML or Patreon list stays private, with the list's username and password, once the list is removed or left. It became public and was shared to the directory (#332). +- A feed's username and password go to its own host alone: not to its episodes on other hosts, nor to the feeds an OPML lists on other hosts, which took the OPML's password on every check (#327, #328). ## [1.4.0] - 2026-10-19 diff --git a/crates/ipx/src/config.rs b/crates/ipx/src/config.rs index b0534d8..c4aa319 100644 --- a/crates/ipx/src/config.rs +++ b/crates/ipx/src/config.rs @@ -300,6 +300,27 @@ impl Feed { pub fn auth(&self) -> Option<(&str, Option)> { self.username.as_deref().map(|u| (u, self.password())) } + + /// `auth()`, for a URL on the feed's own host only. The credential is for whoever issued it, + /// and an enclosure, or a feed an OPML lists, can be on any host its author names (#327, #328). + pub fn auth_for(&self, url: &str) -> Option<(&str, Option)> { + same_host(&self.url, url).then(|| self.auth()).flatten() + } + + /// The username, password and password_env a feed at `url` takes from this one, its OPML or + /// Patreon list: all of them on the list's own host, none elsewhere. + pub fn credentials_for(&self, url: &str) -> (Option, Option, Option) { + if same_host(&self.url, url) { + (self.username.clone(), self.password.clone(), self.password_env.clone()) + } else { + (None, None, None) + } + } +} + +fn same_host(a: &str, b: &str) -> bool { + let host = |u: &str| url::Url::parse(u).ok().and_then(|u| u.host_str().map(str::to_ascii_lowercase)); + host(a).is_some_and(|h| host(b).as_ref() == Some(&h)) } impl Config { @@ -529,6 +550,22 @@ fn expand_tilde(p: &Path) -> PathBuf { mod tests { use super::*; + #[test] + fn a_feeds_password_goes_to_its_own_host_alone() { + let paid = Feed { + url: "https://Members.example.com/feed.xml".into(), + username: Some("me".into()), + password: Some("pw".into()), + ..toml::from_str("url = 'x'").unwrap() + }; + assert_eq!(paid.auth_for("https://members.example.com/ep1.mp3"), Some(("me", Some("pw".into())))); + assert_eq!(paid.auth_for("https://cdn.example.net/ep1.mp3"), None, "an enclosure elsewhere (#328)"); + assert_eq!(paid.auth_for("https://members.example.com.evil.net/x"), None); + assert_eq!(paid.auth_for("not a url"), None); + assert_eq!(paid.credentials_for("https://members.example.com/other.xml").0.as_deref(), Some("me")); + assert_eq!(paid.credentials_for("https://elsewhere.org/feed.xml"), (None, None, None), "an OPML's feed (#327)"); + } + #[test] fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() { let cfg: Config = toml::from_str( diff --git a/crates/ipx/src/download.rs b/crates/ipx/src/download.rs index e2a9edd..6b009f5 100644 --- a/crates/ipx/src/download.rs +++ b/crates/ipx/src/download.rs @@ -133,8 +133,8 @@ pub async fn download( mut on_progress: impl FnMut(u64, Option), ) -> Result { let mut req = client.get(url); - if let Some(user) = &feed_cfg.username { - req = req.basic_auth(user, feed_cfg.password()); + if let Some((user, password)) = feed_cfg.auth_for(url) { + req = req.basic_auth(user, password); } let resp = req.send().await.context("connecting")?; if !resp.status().is_success() { diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index 131c6d3..a362dc2 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -1429,6 +1429,7 @@ pub async fn subscriptions(ctx: &Ctx) -> Result> { } }; let title = m.title.clone().unwrap_or_else(|| m.id.clone()); + let (username, password, password_env) = parent.map(|p| p.credentials_for(&m.url)).unwrap_or_default(); out.push(Sub { id: m.id.clone(), cfg: config::Feed { @@ -1441,9 +1442,9 @@ pub async fn subscriptions(ctx: &Ctx) -> Result> { allow_explicit: parent.is_some_and(|p| p.allow_explicit), auto_download: parent.is_none_or(|p| p.auto_download), max_new_per_check: parent.and_then(|p| p.max_new_per_check), - username: parent.and_then(|p| p.username.clone()), - password: parent.and_then(|p| p.password.clone()), - password_env: parent.and_then(|p| p.password_env.clone()), + username, + password, + password_env, category: None, listed: false, private: false, @@ -1544,6 +1545,7 @@ async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&config::Feed>) - } let mut cfg = ctx.edit_cfg().await; // The list's own settings carry over; its folder and group do not. + let (username, password, password_env) = base.map(|p| p.credentials_for(&m.url)).unwrap_or_default(); cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed { url: m.url.clone(), folder: None, @@ -1554,9 +1556,9 @@ async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&config::Feed>) - allow_explicit: base.is_some_and(|p| p.allow_explicit), auto_download: base.is_none_or(|p| p.auto_download), max_new_per_check: base.and_then(|p| p.max_new_per_check), - username: base.and_then(|p| p.username.clone()), - password: base.and_then(|p| p.password.clone()), - password_env: base.and_then(|p| p.password_env.clone()), + username, + password, + password_env, category: None, listed: false, // Looked up after the list was removed, private was always false, and a private OPML's diff --git a/docs/configuration.md b/docs/configuration.md index 3a2f2e4..cf7415a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -160,6 +160,9 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself and are the same for everyone. See [users.md](users.md). +`username` and its password go to the feed's own host alone: not to episodes on another host, nor to +the feeds an OPML lists on other hosts, which would hand the password to whoever runs them. + **The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets up in Admin, Directory: its address, this server's token and whether to share. They are server settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.