A feed's credentials go to its own host alone (#327 #328)

feed::fetch already sent basic auth only to the feed's own host, but
two paths around it did not:

- download::download attached the feed's username and password to
  every enclosure, on whatever host the feed named: a CDN, or the
  measurement and ad-insertion prefixes podcasts route through (#328).
- subscriptions() and promote_kept() gave every feed an OPML or Patreon
  list derives the list's credentials, and fetch's same-host check then
  compared against the derived feed's own URL, so the list's password
  went to every host the OPML named, on every scan (#327).

config::Feed::auth_for and credentials_for compare hosts once, and
each of the three uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 14:02:10 +00:00
parent 4f7dab9c04
commit 80f12cda70
5 changed files with 51 additions and 8 deletions

View File

@@ -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). - 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 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 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 ## [1.4.0] - 2026-10-19

View File

@@ -300,6 +300,27 @@ impl Feed {
pub fn auth(&self) -> Option<(&str, Option<String>)> { pub fn auth(&self) -> Option<(&str, Option<String>)> {
self.username.as_deref().map(|u| (u, self.password())) 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<String>)> {
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<String>, Option<String>, Option<String>) {
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 { impl Config {
@@ -529,6 +550,22 @@ fn expand_tilde(p: &Path) -> PathBuf {
mod tests { mod tests {
use super::*; 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] #[test]
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() { fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
let cfg: Config = toml::from_str( let cfg: Config = toml::from_str(

View File

@@ -133,8 +133,8 @@ pub async fn download(
mut on_progress: impl FnMut(u64, Option<u64>), mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<Downloaded> { ) -> Result<Downloaded> {
let mut req = client.get(url); let mut req = client.get(url);
if let Some(user) = &feed_cfg.username { if let Some((user, password)) = feed_cfg.auth_for(url) {
req = req.basic_auth(user, feed_cfg.password()); req = req.basic_auth(user, password);
} }
let resp = req.send().await.context("connecting")?; let resp = req.send().await.context("connecting")?;
if !resp.status().is_success() { if !resp.status().is_success() {

View File

@@ -1429,6 +1429,7 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
} }
}; };
let title = m.title.clone().unwrap_or_else(|| m.id.clone()); 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 { out.push(Sub {
id: m.id.clone(), id: m.id.clone(),
cfg: config::Feed { cfg: config::Feed {
@@ -1441,9 +1442,9 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
allow_explicit: parent.is_some_and(|p| p.allow_explicit), allow_explicit: parent.is_some_and(|p| p.allow_explicit),
auto_download: parent.is_none_or(|p| p.auto_download), auto_download: parent.is_none_or(|p| p.auto_download),
max_new_per_check: parent.and_then(|p| p.max_new_per_check), max_new_per_check: parent.and_then(|p| p.max_new_per_check),
username: parent.and_then(|p| p.username.clone()), username,
password: parent.and_then(|p| p.password.clone()), password,
password_env: parent.and_then(|p| p.password_env.clone()), password_env,
category: None, category: None,
listed: false, listed: false,
private: 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; let mut cfg = ctx.edit_cfg().await;
// The list's own settings carry over; its folder and group do not. // 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 { cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed {
url: m.url.clone(), url: m.url.clone(),
folder: None, 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), allow_explicit: base.is_some_and(|p| p.allow_explicit),
auto_download: base.is_none_or(|p| p.auto_download), auto_download: base.is_none_or(|p| p.auto_download),
max_new_per_check: base.and_then(|p| p.max_new_per_check), max_new_per_check: base.and_then(|p| p.max_new_per_check),
username: base.and_then(|p| p.username.clone()), username,
password: base.and_then(|p| p.password.clone()), password,
password_env: base.and_then(|p| p.password_env.clone()), password_env,
category: None, category: None,
listed: false, listed: false,
// Looked up after the list was removed, private was always false, and a private OPML's // Looked up after the list was removed, private was always false, and a private OPML's

View File

@@ -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 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). 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 **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 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. settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.