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 <html or <!doctype html, opened from "View in a new tab" ran its
script on iPX's origin with the viewer's session.

Now every /media response is nosniff, anything but audio, video, a
non-SVG image, a PDF or plain text is sent as an attachment, and all
but PDFs get the sandbox CSP artwork already had. PDFs go without it
because Chrome refuses to render one in a sandboxed document; its
viewer does not run the PDF's script as the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 13:01:00 +00:00
parent ec3dedf113
commit 62a2456dde
2 changed files with 37 additions and 1 deletions

View File

@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security ### Security
- An episode that comes as a torrent downloads into a folder of its own, `torrent-<id>` 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). - An episode that comes as a torrent downloads into a folder of its own, `torrent-<id>` 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 ## [1.4.0] - 2026-10-19

View File

@@ -1264,6 +1264,16 @@ impl IntoResponse for ApiError {
mod tests { mod tests {
use super::*; 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] #[test]
fn a_relative_image_resolves_against_the_post() { fn a_relative_image_resolves_against_the_post() {
let mut b = feed_sanitizer(); 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) => { Ok(mut r) => {
// Never in a shared cache: Cloudflare kept `deadair.mp3`, by its extension, and served // 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). // 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() r.into_response()
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).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)] #[derive(Deserialize)]
struct ArtQuery { struct ArtQuery {
u: String, u: String,