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

@@ -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);