Files
EventSnap/docs/DECISION-media-auth.md
fabi d9025f783a fix(security): port image-decode DoS cap + audit backlog/media-auth docs
From the 2026-06-27 audit branch (fix/audit-2026-06-27-critical-medium), whose
work was ~80% absorbed into main via the batch branches. This ports the pieces
that were NOT in main:

- compression.rs: cap image decode with image::Limits (12000x12000, 256 MiB
  max_alloc) via ImageReader instead of image::open(). The upload body-size cap
  bounds the file on disk but not the *decoded* dimensions, so a small
  decompression-bomb image could OOM the box during decode/resize. Surgical port
  of the audit's decode guard only (not its image/video semaphore split).

- docs/SECURITY-BACKLOG.md: the audit's triaged backlog, reconciled against main
  (each item tagged done / open / contingent-on-signed-media). Records the still-
  open items: host moderation UI gap, owner-delete SSE broadcast, quota
  mount-detection (starts_with) + low-disk guard.

- docs/DECISION-media-auth.md: writes up the one real architectural divergence —
  main serves media unauthenticated (ServeDir + UUID-capability) while the audit
  built a signed/TTL'd gateway. Lays out the tradeoff (leaked URL = permanent vs
  ~24h access; banned-user access) and a recommendation, for a human decision.

Verified: cargo build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:26:57 +02:00

4.6 KiB

Decision: media serving — unauthenticated UUID vs. signed gateway

Status: OPEN — needs a call. Written 2026-06-30 from the 2026-06-27 audit (fix/audit-2026-06-27-critical-medium), which implemented the signed-gateway option that main did not adopt.

Scope: how main serves uploaded photos/videos (originals + previews/thumbnails) to the <img>/<video> tags in the feed, lightbox, and diashow.


The two models

A. Current main — unauthenticated, UUID-as-capability

  • /api/v1/upload/{id}/originalno auth; the unguessable upload UUID is the capability. (Documented as intentional in frontend/src/lib/data-mode-store.ts.)
  • /media/* — static axum ServeDir, no auth layer, serves preview/thumbnail files by path.
  • No expiry, no signature, no per-request authorization on the bytes.
  • Works with plain <img src> (no Authorization header needed) and browser caching, at zero per-request backend cost.

B. Audit branch — authenticated signed gateway

  • /media/{kind}/{id}?sig=… via handlers::media::serve.
  • HMAC signature (keyed off jwt_secret), time-boxed (~24 h, bucketed to a 1 h URL-stability window so warm fetches stay cacheable).
  • Authorizes by signature + uploader visibility, not requester identity.
  • Still <img>-compatible (capability rides in the query string, not a header).
  • Cost: token mint/verify, ~2 DB queries per cold fetch, and the HMAC key currently reuses jwt_secret (see "Media HMAC domain separation" in SECURITY-BACKLOG.md).

What actually differs (the tradeoff)

A. UUID (main) B. Signed gateway (audit)
Leaked URL/UUID (forwarded link, browser history, referer, logs) Permanent full-res access Access expires (~24 h); URL can't be re-minted
Banned / departed guest Retains permanent access to every original whose UUID they hold Can't mint new URLs; can replay held URLs ≤ TTL only
Revocation None (UUID is forever) Rotate the signing key → all outstanding URLs die
Enumeration Mitigated by UUIDv4 unguessability Same, plus signature
Per-request cost Zero (static serve) Token verify + ~2 DB queries (cold)
Plumbing / failure surface Minimal Token mint/verify, key mgmt, cache-bucket logic

Neither model solves the fundamental <img>-can't-send-a-Bearer constraint: once a browser holds a media URL, it is replayable for that URL's lifetime. Model B simply bounds that lifetime and adds revocability; Model A's lifetime is forever.

Threat model (this deployment)

Private single-box event app, ~100 guests, ~1 000 files, one event at a time. Media is shared with all guests by design — it is personal but not secret within the event. The real risk is a URL escaping the event boundary (a guest forwards a link; it lands in chat history, a public post, or server/proxy logs) granting an outsider — or a removed guest — access.

Recommendation

This is a judgment call, not a clear-cut bug, so it's yours to make. My leaning:

  • Adopt Model B (signed gateway) if post-event link leakage or removed-guest access is a real concern for you — it's the materially stronger posture (bounded exposure + key-rotation revocation) and the implementation already exists on the audit branch. If adopted, also do the cheap HKDF domain-separation for the HMAC key (backlog 🅱).
  • Keep Model A (UUID) as an explicitly accepted risk if you're comfortable that a leaked link = permanent access at this scale. If so, two cheap hardening steps are still worth doing:
    1. Log hygiene — ensure the upload UUID never lands in access logs with enough context to correlate (the TraceLayer logs the request path, and /api/v1/upload/{id}/original puts the UUID in the path). Confirm logs aren't shipped/retained where that matters.
    2. Document the decision in README/PROJECT.md so "unauthenticated media" is a recorded choice, not an oversight.

Sharpest single fact to decide on: in Model A, a guest you ban keeps full-resolution access to every original they ever loaded, forever. In Model B, that access dies within ~24 h. If that asymmetry matters for your events, adopt the gateway.

If you adopt B

The implementation lives on origin/fix/audit-2026-06-27-critical-medium: backend/src/handlers/media.rs (+ media_token), the /media/{kind}/{id} route in main.rs, and the feed/upload/host changes that emit signed URLs (models/upload.rs, handlers/feed.rs). It would need re-basing onto current main (which has since diverged across ~80 files).