From 9b2537761f6213818a615b9500c184de592a4538 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 18 Sep 2026 14:23:48 +0000 Subject: [PATCH] Ask for a post's images without a referrer jeffgeerling.com answers 403 to an image request whose Referer is another site, so his posts showed a broken image on iOS and the alt text on desktop. The sanitiser now gives every referrerpolicy="no-referrer". Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ src/web.rs | 22 ++++++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0446d3e..60c0b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ The long form, with what was wrong before and how it was found, is in their own, and on white for an iPhone's home screen. - The file icon of an item not yet downloaded sits level with the rest of its row, instead of higher than a downloaded one's. +- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's, + now show: ipx asks for them without saying it is the page showing them. - An item you open stays read. A list refresh that crossed with marking it read could put its unread dot back until the next refresh. - On the Unread tab, the item you were reading leaves the list as soon as you move to the next diff --git a/src/web.rs b/src/web.rs index 96e8c62..4402e78 100644 --- a/src/web.rs +++ b/src/web.rs @@ -858,12 +858,13 @@ mod tests { #[test] fn a_relative_image_resolves_against_the_post() { - let mut b = ammonia::Builder::new(); + let mut b = feed_sanitizer(); let html = r#"x"#; let out = clean_description(&mut b, html, Some("https://example.com/2026/04/post/")); assert!(out.contains(r#"src="https://example.com/2026/04/post/images/a.webp""#), "{out}"); assert!(out.contains(r#"href="https://example.com/about""#), "{out}"); assert!(!out.contains("bad()"), "{out}"); + assert!(out.contains(r#"referrerpolicy="no-referrer""#), "{out}"); assert!(clean_description(&mut b, html, None).contains(r#"src="images/a.webp""#)); } @@ -1046,11 +1047,7 @@ fn entry_page( ); let mut rows = db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?; - // Feed HTML is untrusted: it reaches the page only after ammonia has been through it. - // Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already - // keeps that safe -- so following one in show notes never navigates away from ipx. - let mut sanitizer = ammonia::Builder::new(); - sanitizer.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank"); + let mut sanitizer = feed_sanitizer(); for row in &mut rows { if let Some(d) = &row.description { row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref())); @@ -1060,6 +1057,19 @@ fn entry_page( Ok(Json(EntryPage { total, entries: rows })) } +/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it. +fn feed_sanitizer() -> ammonia::Builder<'static> { + let mut b = ammonia::Builder::new(); + // Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already + // keeps that safe -- so following one in show notes never navigates away from ipx. + b.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank"); + // An image is asked for without saying it is shown on ipx. A site that refuses images to + // other sites' pages goes by that: jeffgeerling.com answers 403, and his posts showed a + // broken image on iOS and only the alt text on desktop. + b.add_tag_attributes("img", &["referrerpolicy"]).set_tag_attribute_value("img", "referrerpolicy", "no-referrer"); + b +} + /// A relative `src` or `href` in a post means relative to the post, not to ipx: The /// Observation Deck's `images/37k-a-day-bro.webp` came up as a broken image. fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<&str>) -> String {