fix(security): harden authz, media gating, recover, SSE, deps

Security audit follow-up. No Critical/High existed; these close the
Medium/Low findings.

F1 [Med] Host privilege boundary: a host could demote a peer host to
  guest and then ban / PIN-reset (→ account takeover via /recover) them,
  because the ban/pin-reset peer guards key off the target's *current*
  role. set_role now blocks a non-admin from demoting a host.

F2 [Med] Moderation now revokes preview/thumbnail access: they are served
  through visibility-checked aliases (/api/v1/upload/{id}/{preview,
  thumbnail}) that filter soft-deleted / ban-hidden uploads, and direct
  /media/previews|thumbnails is 404-blocked. Feed emits the gated URLs;
  Caddy keeps them privately cacheable (max-age=300, short so moderation
  revokes promptly — the live feed already evicts cards via SSE). Uses a
  lean find_visible_media() (paths+mime only) on the media hot path.

F3 [Med/Low] npm audit fix: svelte 5.55.1→5.56.4 (SSR-XSS advisories),
  devalue 5.6.4→5.8.1 (DoS). Prod deps: 0 vulnerabilities.

F4 [Low] /recover no longer leaks account existence: unknown display name
  returns the same 401 as a wrong PIN, and runs a dummy bcrypt verify so
  timing matches — closing the enumeration + timing oracle.

F5 [Low] SSE streams re-validate the session every ~60s and close once it
  is logged out / expired, instead of running until client disconnect.

F6 [Info] X-Content-Type-Options: nosniff set at the app layer on all
  media responses (defense-in-depth alongside the edge).

Tests: new e2e specs for F1 (host cannot demote peer host), F2 (preview
gated + 404 after delete + direct /media blocked), F4 (uniform 401).
40 backend unit tests + 182 e2e passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-08 06:14:00 +02:00
parent 683b1b6f65
commit a4b2c5bd1c
13 changed files with 508 additions and 225 deletions

View File

@@ -527,35 +527,26 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
}
}
/// Streaming download of the original file behind an upload. Used by:
/// - the per-post "Original anzeigen" context action (`window.open`)
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
/// Data Mode = Original
///
/// **Auth model:** the route is intentionally unauthenticated so it works from
/// `<img src>` / `window.open`, matching how preview + thumbnail variants are served.
/// The URL contains the upload's unguessable UUID. Unlike raw `/media` files, this
/// alias is the *only* way to fetch an original: direct `/media/originals/**` access is
/// blocked in the router, and this handler filters out soft-deleted and ban-hidden
/// uploads (via `find_by_id_visible`) so moderation actually removes access to content.
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
/// Stream a media file from disk into an HTTP response with a fixed set of security
/// headers. Every media response (original, preview, thumbnail) goes through here so
/// they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against
/// content-type confusion, even if the edge proxy is bypassed) plus an explicit
/// `Content-Disposition` and `Cache-Control`.
async fn stream_media_file(
absolute: &std::path::Path,
content_type: String,
disposition: &str,
cache_control: &str,
) -> Result<axum::response::Response, AppError> {
let upload = Upload::find_by_id_visible(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let absolute = state.config.media_path.join(&upload.original_path);
if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
use axum::body::Body;
use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&absolute)
if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
let file = tokio::fs::File::open(absolute)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let metadata = file
@@ -564,19 +555,86 @@ pub async fn get_original(
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.header(header::CACHE_CONTROL, cache_control)
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
}
/// Streaming download of the original file behind an upload. Used by:
/// - the per-post "Original anzeigen" context action (`window.open`)
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
/// Data Mode = Original
///
/// **Auth model:** the route is intentionally unauthenticated so it works from
/// `<img src>` / `window.open`. The URL contains the upload's unguessable UUID. Unlike
/// raw `/media` files, this alias is the *only* way to fetch an original: direct
/// `/media/originals/**` access is blocked in the router, and this handler filters out
/// soft-deleted and ban-hidden uploads (via `find_visible_media`) so moderation actually
/// removes access to content. Preview and thumbnail variants are gated the same way (see
/// [`get_preview`] / [`get_thumbnail`]).
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let absolute = state.config.media_path.join(&media.original_path);
let filename = absolute
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("original");
let disposition = format!("attachment; filename=\"{filename}\"");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, upload.mime_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
// Full-res original: force download, never cache at the edge.
stream_media_file(&absolute, media.mime_type, &disposition, "no-store").await
}
/// Streaming access to an upload's compressed **preview** image. Gated exactly like
/// [`get_original`]: `find_visible_media` drops soft-deleted / ban-hidden uploads, so a
/// deleted or moderated post's preview 404s here even though the file may still be on
/// disk. Direct `/media/previews/**` is blocked in the router, making this the only path
/// to a preview.
///
/// Served inline (it's an `<img src>`) and privately cacheable for a short window. The
/// window is intentionally short (5 min) so a moderated image stops being served to a
/// direct-URL holder promptly; the live feed already evicts the card instantly via the
/// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case.
pub async fn get_preview(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let rel = media
.preview_path
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel);
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
}
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
/// [`get_preview`].
pub async fn get_thumbnail(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let rel = media
.thumbnail_path
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel);
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
}
#[cfg(test)]