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>
5.6 KiB
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
mainadopts the audit's signed-media gateway (see DECISION-media-auth.md).
The audit branch additionally implemented an authenticated, signed media gateway that
maindid not adopt —mainserves media unauthenticated (staticServeDir+ UUID-capability/api/v1/upload/{id}/original). That architectural choice is written up separately in DECISION-media-auth.md; the "by-design notes" at the bottom of this file describe the audit branch's model and apply tomainonly if that gateway is adopted.
🅱 Worth a tracked ticket (real, not one-liners)
-
⬜ Moderation UI gap — the backend
DELETE /host/upload/{id}andDELETE /host/comment/{id}endpoints have no frontend caller, so a host cannot remove a guest's content from the UI (the feedContextSheetonly offers delete for the viewer's own uploads —target.user_id === myUserId). Still a functional hole inmain. 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 singlelike-update/new-comment/upload-processedcallingloadFeed(true)and collapsing a scrolled feed) was fixed in the UX batch:mainnow patches the affected card in place (patchCount) and debounces processing (scheduleInPlaceRefresh). ⬜ Remaining sub-item: owner-deleted uploads are still not broadcast —delete_uploadreturns204with noupload-deletedSSE, so other clients keep showing a deleted post until refresh (only host deletes broadcast). Emitupload-deletedfrom 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_secretas 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 tomainas it stands (no signed media). -
⬜ Quota mount-detection + low-disk guard — Still applies to
main.compute_storage_quota(backend/src/handlers/upload.rs) andadmin.rspick the disk viastarts_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): event-scoped social
handlers (cross-event authz), server-side MIME/ext allowlist on upload, recovery-PIN lockout
backoff, unspoofable client IP in the rate limiter, effective JWT production-secret guard, DB port
no longer publicly exposed, container healthchecks, bcrypt offloaded via spawn_blocking,
bounded compression concurrency (semaphore), bounded feed queries (LIMIT ≤ 100), and the
viewport-fit / reduced-motion / aria a11y pass. New since the audit: an image-decode
decompression-bomb cap (image::Limits 12000×12000 / 256 MiB) ported into
backend/src/services/compression.rs.
🅲 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
HashMapkey LRU/cap (attacker-chosenrecover:{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.
By-design notes (audit branch's signed-media model — see DECISION-media-auth.md)
These describe the audit branch's signed-gateway model.
maindoes not serve media this way; formain's actual media-access posture and the tradeoff, see 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
<img>load, which the<img>-can't-send-a-Bearer constraint precludes. - Two DB queries per cold media fetch (
uploadrow + uploader row). Mitigated by browser caching and the stable bucketed URL. Could be one JOIN if it ever shows up in profiling.