# Security & Hardening Backlog Tracks the deliberately-deferred items from the 2026-06-27 security audit and its review passes. **Provenance & reconciliation.** This document was extracted from the `fix/audit-2026-06-27-critical-medium` branch and reconciled against `main` on 2026-06-30. That audit's Critical→Medium findings were **largely re-implemented into `main`** through the later batch branches (security-review-followups, security-review-batch-2, the UX batches) rather than by merging the audit branch — so `git` shows no merge, but the controls are present. Each item below is tagged with its **current status in `main`**: - ✅ **Done in main** — addressed (possibly via a different implementation). - ⬜ **Open** — still applies to `main`. - 🔀 **Contingent** — only relevant if `main` adopts the audit's signed-media gateway (see [DECISION-media-auth.md](DECISION-media-auth.md)). > The audit branch additionally implemented an **authenticated, signed media gateway** that `main` > did **not** adopt — `main` serves media unauthenticated (static `ServeDir` + UUID-capability > `/api/v1/upload/{id}/original`). That architectural choice is written up separately in > [DECISION-media-auth.md](DECISION-media-auth.md); the "by-design notes" at the bottom of this > file describe the *audit branch's* model and apply to `main` only if that gateway is adopted. --- ## 🅱 Worth a tracked ticket (real, not one-liners) - ⬜ **Moderation UI gap** — the backend `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` endpoints have **no frontend caller**, so a host cannot remove a *guest's* content from the UI (the feed `ContextSheet` only offers delete for the viewer's *own* uploads — `target.user_id === myUserId`). Still a functional hole in `main`. Needs a host-facing "remove" action wired to those endpoints, gated on host/admin role. - ✅/⬜ **Feed reactivity** — *Mostly fixed in `main`.* The full-reload-on-every-SSE-event problem (a single `like-update`/`new-comment`/`upload-processed` calling `loadFeed(true)` and collapsing a scrolled feed) was fixed in the UX batch: `main` now patches the affected card in place (`patchCount`) and debounces processing (`scheduleInPlaceRefresh`). ⬜ **Remaining sub-item:** owner-deleted uploads are still not broadcast — `delete_upload` returns `204` with no `upload-deleted` SSE, so other clients keep showing a deleted post until refresh (only host deletes broadcast). Emit `upload-deleted` from the owner delete path too. - 🔀 **Media HMAC domain separation** — *Only applies if the signed-media gateway is adopted.* The audit's signed-media tokens reuse `jwt_secret` as the HMAC key; a dedicated derived key (`HKDF(jwt_secret, "media-url")`) would isolate the domains so a future change to one can't weaken the other. N/A to `main` as it stands (no signed media). - ⬜ **Quota mount-detection + low-disk guard** — *Still applies to `main`.* `compute_storage_quota` (`backend/src/handlers/upload.rs`) and `admin.rs` pick the disk via `starts_with`, so root (`/`) is a wildcard prefix that can match the wrong device; and there's no hard min-free-space precheck when the quota is disabled. Use longest-prefix match; add an unconditional 507/429 when free space is critically low. ## ✅ Fixed in main since the audit (for the record) These audit findings are present in `main` today (verified 2026-06-30): server-side MIME/ext allowlist on upload, recovery-PIN lockout backoff, DB port no longer publicly exposed, bcrypt offloaded via `spawn_blocking`, bounded compression concurrency (semaphore), bounded feed queries (`LIMIT ≤ 100`), and the viewport-fit / reduced-motion / aria a11y pass. An image-decode decompression-bomb cap (`image::Limits` 12000×12000 / 256 MiB) lives in `backend/src/services/compression.rs`. **Fixed in the 2026-07 review pass** (previously *claimed* fixed here but were not — corrected): - **Effective JWT production-secret guard** — `APP_ENV=production` is now set for the app service, and `config.rs::validate_secrets` rejects any placeholder-ish `JWT_SECRET`/`ADMIN_PASSWORD_HASH` (not just the exact dev sentinel) and enforces `len ≥ 32` unconditionally in prod. - **Unspoofable client IP in the rate limiter** — `client_ip` now takes the right-most `X-Forwarded-For` entry (the hop Caddy appends), so a client-supplied left-most value is ignored. - **Live role/ban re-check** — the auth extractor re-reads the user row and trusts the DB `role` and `is_banned` flag rather than the JWT claim, so a demote/ban takes effect immediately (no 30-day token window). Bans are enforced on write handlers + the host/admin extractors, preserving the documented read-only access for banned guests (USER_JOURNEYS §10); demoted hosts lose host powers at once. - **Container healthchecks** — `app` and `frontend` now have healthchecks and Caddy waits on `service_healthy`. - **Event-scoped `ban_user`** — the ban UPDATE is now scoped by `event_id` like its siblings. ## 🅲 Consciously won't-fix at ~100-guest single-box scale Diminishing returns vs. the deployment's actual threat model. Revisit only if the scale or tenancy model changes. - Rate-limiter `HashMap` key LRU/cap (attacker-chosen `recover:{ip}:{name}` keys accumulate up to the 24h prune ceiling) — bounded and pruned; not worth an LRU. - "Last host" / host↔host role-churn guard — operational, low blast radius. - Performance micro-indexes (`idx_like_user_upload`, comment pagination index) — current queries are sub-ms at this row count. - Optimistic-like in-flight guard, ownership-snapshot-at-mount, assorted copy tweaks — UX polish. - **Mid-session ban does not tear down an already-open SSE stream.** The live-ban check lives in the `AuthUser` extractor, but the stream authenticates via ticket→session (`handlers/sse.rs::stream`), so a user banned while connected keeps receiving broadcast events until their stream drops. New tickets *are* blocked (`issue_ticket` uses `AuthUser`), so they cannot reconnect. Low blast radius (read-only feed events, no re-subscribe); tearing live streams down would need a per-session broadcast filter. Revisit only if bans must take effect within seconds. ## By-design notes (audit branch's signed-media model — see DECISION-media-auth.md) > These describe the **audit branch's** signed-gateway model. `main` does **not** serve media this > way; for `main`'s actual media-access posture and the tradeoff, see > [DECISION-media-auth.md](DECISION-media-auth.md). - **Banned user retains ≤24h media access via already-held signed URLs.** The audit's media gateway authorizes by *signature + uploader visibility*, not requester identity, and signed URLs are time-boxed (24h, bucketed). A banned/revoked-session user cannot mint new URLs (every API call 401/403s) but can replay URLs they already hold until expiry — only for content they already saw. Accepted there: tightening would require per-request identity on every `` load, which the ``-can't-send-a-Bearer constraint precludes. - **Two DB queries per *cold* media fetch** (`upload` row + uploader row). Mitigated by browser caching and the stable bucketed URL. Could be one JOIN if it ever shows up in profiling.