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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user