//! Addresses that carry someone's access: taking it out of what is logged, and keeping such feeds private. /// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so /// each hop is seen. The second value is where the feed now is when every hop said so for good /// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather /// than be redirected on every read. A temporary redirect (302, 307) moves nothing. /// `system-proxy` reads http_proxy/https_proxy. /// `auth` is a username and password, sent only to the feed's own host. /// An address as a trace may keep it: without its query or user and password, where a private /// feed carries its credential (Patreon's `?auth=`), which Tempo kept for anyone reading it (#225). /// shortcut: a token in the path (`/feed/.xml`) stays; strip paths too if a feed does that. #[must_use] pub fn redact(url: &str) -> String { match url::Url::parse(url) { Ok(mut u) => { u.set_query(None); u.set_fragment(None); let _ = u.set_username(""); let _ = u.set_password(None); u.to_string() } // Not an address ipx could fetch; keep what comes before anything that could be a query. Err(_) => url.split(['?', '#', '@']).next().unwrap_or_default().to_owned(), } } /// Text with each address in it redacted, for what is logged and sent to every open page: an /// error from reqwest names the whole address it fetched, a private feed's key with it (#238). #[must_use] pub fn redact_text(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find("http://").into_iter().chain(rest.find("https://")).min() { out.push_str(&rest[..i]); let end = rest[i..].find(|c: char| c.is_whitespace() || "()<>\"'".contains(c)).map_or(rest.len(), |n| i + n); out.push_str(&redact(&rest[i..end])); rest = &rest[end..]; } out.push_str(rest); out } /// An address that carries a subscriber's own access: a paid-feed host, credentials in the URL, /// or a key in the query. ipx leaves such a feed out of anything others see, and the directory /// refuses one sent to it (#202). /// /// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets /// through; a per-feed private flag is the upgrade (#205). #[must_use] pub fn url_looks_private(feed_url: &str) -> bool { let Ok(u) = url::Url::parse(feed_url) else { return true }; // Paid-feed services put the subscriber's key in the path as often as in the query, and a // long path segment alone proves nothing (acast's public show ids look the same). So a // feed from one of them is private whatever its URL looks like. Supercast is why this // exists: `feeds.supercast.com/feeds/` reached the directory before it did. let host = u.host_str().unwrap_or(""); PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}"))) || !u.username().is_empty() || u.password().is_some() || u.query_pairs().any(|(k, _)| { let k = k.to_ascii_lowercase(); ["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"].iter().any(|w| k.contains(w)) }) } /// Services whose feeds are always one subscriber's own. pub(crate) const PAID_HOSTS: &[&str] = &["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"]; #[cfg(test)] mod tests { #[test] fn redact_drops_what_carries_a_credential() { assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x"); assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml"); assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml"); assert_eq!(super::redact("not a url?token=SECRET"), "not a url"); // As reqwest words a failure, and with two addresses and none. assert_eq!( super::redact_text( "connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out" ), "connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out" ); assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g"); assert_eq!(super::redact_text("HTTP 404 Not Found"), "HTTP 404 Not Found"); } #[test] fn an_address_carrying_someones_access_looks_private() { let private = super::url_looks_private; assert!(!private("https://feeds.twit.tv/twit.xml")); // Patreon's shape: the key is a query parameter. assert!(private("https://www.patreon.com/rss/x?auth=abc123&show=2073588")); assert!(private("https://example.com/rss?api_key=abc")); // Paid-feed services put the key in the path; the host gives them away. assert!(private("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx")); assert!(private("https://someshow.supportingcast.fm/content/abc123.rss")); // A long path segment alone is not a key: acast's public show ids look the same. assert!(!private("https://feeds.acast.com/public/shows/0123456789abcdef01234567")); assert!(private("https://ray:hunter2@example.com/rss")); assert!(private("not a url"), "unparseable is not safe to list"); } }