From 00c22aa1951683bbd7163b394ea147bd17afd18d Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 5 Sep 2026 09:03:13 +0200 Subject: [PATCH] fix: never serve an upload as an executable type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MIME came straight from the client's multipart Content-Type and was handed back verbatim with Content-Disposition: inline. A file uploaded as text/html therefore ran as script on the diary's own origin, with the session cookie's full authority — and share::serve_media exposed the same path to anyone holding a share link, without a session at all. Uploads are now pinned to an allowlist of types that cannot execute (image except SVG, video, audio, text/plain, application/pdf); everything else is stored and served as application/octet-stream + attachment. The check runs on the way out as well as on the way in, so rows written before this commit are covered too. Media responses also carry nosniff and a sandbox CSP. --- src/routes/media.rs | 85 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/src/routes/media.rs b/src/routes/media.rs index f1e894c..a4787c5 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -26,6 +26,39 @@ static MEDIA_REF: LazyLock = LazyLock::new(|| { .expect("static regex is valid") }); +/// An upload is attacker-controlled content served from our own origin, so the +/// browser is only ever told it is a type that cannot execute script. Anything +/// else — `text/html`, `image/svg+xml`, an unrecognised type — is stored and +/// served as an opaque download instead. +fn sanitize_mime(raw: &str) -> String { + let base = raw + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + + let (top, sub) = base.split_once('/').unwrap_or_default(); + let inlineable = match top { + // SVG is a document: it can carry