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:
@@ -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-<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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user