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>
80 lines
4.6 KiB
Markdown
80 lines
4.6 KiB
Markdown
# 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 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](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).
|