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 <img> referrerpolicy="no-referrer".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:23:48 +00:00
parent 3b00e2721a
commit 9b2537761f
2 changed files with 18 additions and 6 deletions

View File

@@ -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. 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 - 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. 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 - 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. 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 - On the Unread tab, the item you were reading leaves the list as soon as you move to the next

View File

@@ -858,12 +858,13 @@ mod tests {
#[test] #[test]
fn a_relative_image_resolves_against_the_post() { fn a_relative_image_resolves_against_the_post() {
let mut b = ammonia::Builder::new(); let mut b = feed_sanitizer();
let html = r#"<img src="images/a.webp"><a href="/about">x</a><script>bad()</script>"#; let html = r#"<img src="images/a.webp"><a href="/about">x</a><script>bad()</script>"#;
let out = clean_description(&mut b, html, Some("https://example.com/2026/04/post/")); 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#"src="https://example.com/2026/04/post/images/a.webp""#), "{out}");
assert!(out.contains(r#"href="https://example.com/about""#), "{out}"); assert!(out.contains(r#"href="https://example.com/about""#), "{out}");
assert!(!out.contains("bad()"), "{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""#)); assert!(clean_description(&mut b, html, None).contains(r#"src="images/a.webp""#));
} }
@@ -1046,11 +1047,7 @@ fn entry_page(
); );
let mut rows = let mut rows =
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?; 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. let mut sanitizer = feed_sanitizer();
// 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");
for row in &mut rows { for row in &mut rows {
if let Some(d) = &row.description { if let Some(d) = &row.description {
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref())); 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 })) 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 /// 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. /// 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 { fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<&str>) -> String {