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