From 62a2456ddee7adef2338c881e3831696c0433338 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 23 Oct 2026 13:01:00 +0000 Subject: [PATCH] Downloads open in the browser only when they cannot run script (#319) /media served a downloaded file with ServeFile, which types it by its extension, and the file and its name are the publisher's. An enclosure saved as .svg or .html, which download::sniff lets through unless it starts --- crates/ipx/CHANGELOG.md | 1 + crates/ipx/src/web.rs | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index fb5e4b5..ba17272 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - An episode that comes as a torrent downloads into a folder of its own, `torrent-` in its podcast's folder. A torrent could name a path anywhere on the server for Delete, the cleanup and playback to act on, and could overwrite other episodes' files of the same name (#318, #331). +- A downloaded file opens in the browser only when it is audio, video, an image other than SVG, a PDF or plain text, and is saved otherwise. An SVG or web page a podcast sent ran its script as iPX, signed in as whoever opened it (#319). ## [1.4.0] - 2026-10-19 diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 4302bbe..c3d06b7 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -1264,6 +1264,16 @@ impl IntoResponse for ApiError { mod tests { use super::*; + #[test] + fn only_inert_downloads_open_in_the_browser() { + for t in ["audio/mpeg", "video/mp4", "image/jpeg", "application/pdf", "text/plain; charset=utf-8"] { + assert!(opens_inline(t), "{t}"); + } + for t in ["image/svg+xml", "text/html", "application/xhtml+xml", "text/xml", "application/octet-stream", ""] { + assert!(!opens_inline(t), "{t} could run script as iPX"); + } + } + #[test] fn a_relative_image_resolves_against_the_post() { let mut b = feed_sanitizer(); @@ -2133,13 +2143,38 @@ async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Reque Ok(mut r) => { // Never in a shared cache: Cloudflare kept `deadair.mp3`, by its extension, and served // its copy to anyone Access let in, without the subscriber check above (#189). - r.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private")); + let h = r.headers_mut(); + h.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private")); + // The file and its name are the publisher's, and ServeFile types it by extension, so an + // `.svg` or `.html` opened from the page ran its script as iPX, with the viewer's + // cookies (#319). Media and PDFs open in the browser; anything else is saved. + h.insert(header::X_CONTENT_TYPE_OPTIONS, header::HeaderValue::from_static("nosniff")); + let kind = h.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned(); + if !opens_inline(&kind) { + h.insert(header::CONTENT_DISPOSITION, header::HeaderValue::from_static("attachment")); + } + // Not on a PDF: Chrome refuses to show one in a sandboxed page. + if !kind.starts_with("application/pdf") { + h.insert(header::CONTENT_SECURITY_POLICY, header::HeaderValue::from_static(MEDIA_CSP)); + } r.into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } +const MEDIA_CSP: &str = "default-src 'none'; media-src 'self'; img-src 'self'; style-src 'unsafe-inline'; sandbox"; + +/// A downloaded file's type that cannot run script on iPX's address when opened on its own. +fn opens_inline(content_type: &str) -> bool { + let t = content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase(); + t.starts_with("audio/") + || t.starts_with("video/") + || (t.starts_with("image/") && !t.contains("svg")) + || t == "application/pdf" + || t == "text/plain" +} + #[derive(Deserialize)] struct ArtQuery { u: String,