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 {