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:
@@ -121,6 +121,18 @@ pub struct RecoverResponse {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
/// A real cost-12 bcrypt hash of a fixed dummy value, computed once and cached. Used
|
||||
/// to run a constant-time-ish verify on the "unknown display name" branch of
|
||||
/// [`recover`], so that branch costs the same as a real (user-exists) verify and can't
|
||||
/// be told apart by timing.
|
||||
fn dummy_pin_hash() -> &'static str {
|
||||
static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
HASH.get_or_init(|| {
|
||||
bcrypt::hash("eventsnap-dummy-not-a-real-pin", 12)
|
||||
.expect("bcrypt hashing of a static dummy value cannot fail")
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn recover(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -158,9 +170,13 @@ pub async fn recover(
|
||||
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
||||
|
||||
if users.is_empty() {
|
||||
return Err(AppError::NotFound(
|
||||
"Kein Benutzer mit diesem Namen gefunden.".into(),
|
||||
));
|
||||
// No user with this name. Run a throwaway bcrypt verify so this branch takes
|
||||
// the same time as the user-exists path, and return the SAME error as a wrong
|
||||
// PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
|
||||
// timing. Display names are already public on the feed, but this still closes
|
||||
// the /recover enumeration + timing oracle.
|
||||
let _ = bcrypt::verify(&body.pin, dummy_pin_hash());
|
||||
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
|
||||
}
|
||||
|
||||
for user in &users {
|
||||
|
||||
@@ -139,8 +139,17 @@ pub async fn feed(
|
||||
let uploads = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let preview_url = r.preview_path.map(|p| format!("/media/{p}"));
|
||||
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}"));
|
||||
// Gated media aliases (visibility-checked, direct /media blocked). Emit the
|
||||
// URL only when the variant actually exists — the URL is what signals the
|
||||
// client which variant to load.
|
||||
let preview_url = r
|
||||
.preview_path
|
||||
.as_ref()
|
||||
.map(|_| format!("/api/v1/upload/{}/preview", r.id));
|
||||
let thumbnail_url = r
|
||||
.thumbnail_path
|
||||
.as_ref()
|
||||
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id));
|
||||
FeedUpload {
|
||||
liked_by_me: liked_set.contains(&r.id),
|
||||
id: r.id,
|
||||
@@ -225,8 +234,14 @@ pub async fn feed_delta(
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
uploader_name: r.uploader_name,
|
||||
preview_url: r.preview_path.map(|p| format!("/media/{p}")),
|
||||
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")),
|
||||
preview_url: r
|
||||
.preview_path
|
||||
.as_ref()
|
||||
.map(|_| format!("/api/v1/upload/{}/preview", r.id)),
|
||||
thumbnail_url: r
|
||||
.thumbnail_path
|
||||
.as_ref()
|
||||
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)),
|
||||
mime_type: r.mime_type,
|
||||
caption: r.caption,
|
||||
like_count: r.like_count,
|
||||
|
||||
@@ -219,9 +219,14 @@ pub async fn set_role(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
if target.0 == "admin" {
|
||||
// Admins are untouchable by hosts. A plain host also may not demote another
|
||||
// *host*: without this guard a host could demote a peer host to guest and then
|
||||
// ban / PIN-reset (→ account-takeover via /recover) them — the ban/pin-reset peer
|
||||
// guards key off the target's *current* role, so a prior demotion would launder
|
||||
// past them. Only an admin may change a host's role.
|
||||
if target.0 == "admin" || (target.0 == "host" && auth.role != UserRole::Admin) {
|
||||
return Err(AppError::Forbidden(
|
||||
"Admins können nicht geändert werden.".into(),
|
||||
"Du darfst die Rolle dieses Benutzers nicht ändern.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ pub async fn stream(
|
||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
|
||||
|
||||
let rx = state.sse_tx.subscribe();
|
||||
let stream = BroadcastStream::new(rx).filter_map(|msg| match msg {
|
||||
let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
|
||||
Ok(sse_event) => Some(Ok(Event::default()
|
||||
.event(sse_event.event_type)
|
||||
.data(sse_event.data))),
|
||||
@@ -73,6 +73,29 @@ pub async fn stream(
|
||||
}
|
||||
});
|
||||
|
||||
// The session is only checked once at open. Re-validate it periodically so a
|
||||
// logged-out or expired session's stream is closed rather than kept alive until the
|
||||
// client happens to disconnect. Bounds a stale stream to ~60s. Only a *definitively*
|
||||
// gone/expired session ends the stream; a transient DB error just retries next tick.
|
||||
let pool = state.pool.clone();
|
||||
let session_hash = token_hash.clone();
|
||||
let session_gone = async move {
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(60));
|
||||
ticker.tick().await; // consume the immediate first tick
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match Session::find_by_token_hash(&pool, &session_hash).await {
|
||||
Ok(Some(_)) => {} // still valid — keep streaming
|
||||
Ok(None) => break, // logged out or expired — stop
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let stream = futures::StreamExt::take_until(events, Box::pin(session_gone));
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(30))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -77,6 +77,17 @@ async fn main() -> Result<()> {
|
||||
"/api/v1/upload/{id}/original",
|
||||
get(handlers::upload::get_original),
|
||||
)
|
||||
// Preview/thumbnail variants are gated the same way as originals (visibility
|
||||
// check + direct /media block below) so moderation actually revokes access to
|
||||
// the displayed images, not just the full-res download.
|
||||
.route(
|
||||
"/api/v1/upload/{id}/preview",
|
||||
get(handlers::upload::get_preview),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/upload/{id}/thumbnail",
|
||||
get(handlers::upload::get_thumbnail),
|
||||
)
|
||||
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
||||
.route("/api/v1/me/context", get(handlers::me::get_context))
|
||||
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
||||
@@ -145,16 +156,26 @@ async fn main() -> Result<()> {
|
||||
let router = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
// Block direct HTTP access to originals. They live under `media_path` (so the
|
||||
// compression worker and export can read them off disk) but must NOT be pullable
|
||||
// straight from `/media/originals/**` — that would bypass the visibility checks in
|
||||
// `get_original` (soft-delete + ban-hide). Every legitimate original fetch goes
|
||||
// through `/api/v1/upload/{id}/original`; previews/thumbnails under `/media` stay
|
||||
// public. The more specific nest takes precedence over `/media` below.
|
||||
// Block direct HTTP access to ALL media subtrees. The files live under
|
||||
// `media_path` (so the compression worker and export can read them off disk) but
|
||||
// must NOT be pullable straight from `/media/**` — that bypasses the visibility
|
||||
// checks (soft-delete + ban-hide) in the gated handlers. Every legitimate fetch
|
||||
// goes through `/api/v1/upload/{id}/{original,preview,thumbnail}`, which filter
|
||||
// via `find_visible_media`. The more specific nests take precedence over the
|
||||
// `/media` ServeDir below (which, with all three subtrees blocked, now serves
|
||||
// nothing — kept as a backstop).
|
||||
.nest_service(
|
||||
"/media/originals",
|
||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
||||
)
|
||||
.nest_service(
|
||||
"/media/previews",
|
||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
||||
)
|
||||
.nest_service(
|
||||
"/media/thumbnails",
|
||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
||||
)
|
||||
.nest_service("/media", media_service)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
@@ -35,6 +35,16 @@ pub struct UploadDto {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Minimal projection of an upload's on-disk file paths, used by the visibility-gated
|
||||
/// media aliases so they don't hydrate the entire `Upload` row per request.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct VisibleMedia {
|
||||
pub original_path: String,
|
||||
pub preview_path: Option<String>,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
impl Upload {
|
||||
/// Takes any executor so the caller can run it inside a transaction (atomic
|
||||
/// quota + insert) or standalone against the pool.
|
||||
@@ -74,14 +84,22 @@ impl Upload {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`Self::find_by_id`] but also excludes uploads whose owner has been
|
||||
/// ban-hidden (`user.uploads_hidden`). Used by the public original-file alias
|
||||
/// so that moderation which hides a user's content from the feed/export also
|
||||
/// stops their full-resolution originals from being pulled by UUID. Mirrors the
|
||||
/// `uploads_hidden` filter that `v_feed` already applies to the feed.
|
||||
pub async fn find_by_id_visible(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT up.* FROM upload up
|
||||
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
|
||||
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
||||
/// excluding soft-deleted rows and ban-hidden owners (`user.uploads_hidden`), the
|
||||
/// same filter `v_feed` applies. So moderation that removes a post from the feed
|
||||
/// also stops its original/preview/thumbnail from being pulled by UUID.
|
||||
///
|
||||
/// Selects four columns instead of the whole `Upload` row: this runs once per image
|
||||
/// per cache-miss on the media hot path, so we avoid hydrating fields the response
|
||||
/// never uses.
|
||||
pub async fn find_visible_media(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
) -> Result<Option<VisibleMedia>, sqlx::Error> {
|
||||
sqlx::query_as::<_, VisibleMedia>(
|
||||
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
|
||||
FROM upload up
|
||||
JOIN \"user\" u ON u.id = up.user_id
|
||||
WHERE up.id = $1 AND up.deleted_at IS NULL AND u.uploads_hidden = false",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user