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>
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}/original— no auth; the unguessable upload UUID is the capability. (Documented as intentional infrontend/src/lib/data-mode-store.ts.)/media/*— staticaxumServeDir, 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=…viahandlers::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:
- Log hygiene — ensure the upload UUID never lands in access logs with enough context to
correlate (the
TraceLayerlogs the request path, and/api/v1/upload/{id}/originalputs the UUID in the path). Confirm logs aren't shipped/retained where that matters. - Document the decision in
README/PROJECT.mdso "unauthenticated media" is a recorded choice, not an oversight.
- Log hygiene — ensure the upload UUID never lands in access logs with enough context to
correlate (the
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).