feat/security: tokenized, unguessable media filenames (closes #11 media leak) #21
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
m's chosen resolution for the #11 password-media gap: page-protection is enough IF media filenames are tokenized and not guessable.
Today media is stored at predictable keys:
{token}/0.png,{token}/1.png,{token}/video/0.mp4. The card{token}is unguessable, but the per-file suffix is sequential — so anyone who has the card link (token) can construct the media object URL and fetch it from the public bucket without the password, defeating #11's gate for the raw media.Fix
Give each media object its own random, unguessable filename component, e.g.
{token}/{rand}.png/{token}/{rand}.mp4where{rand}is a fresh crypto-random token per file (base64url, ~128-bit, like the card token). The public URL is then only reconstructable if you know{rand}, which is only revealed on the rendered (password-gated) card page. So: page-gating + unguessable media URLs ⇒ having the card link alone no longer exposes the media. No private bucket, no proxy.storage_keyalready drives the render URL (URL derived at render time), so storing a random key Just Works on the render side — change is in the upload/key-generation path (internal/serverupload handlers + thestoresave).{token}/…prefix so admin-delete + per-card cleanup still work by token prefix.Constraints
{token}/0.png404s for a new card).Sequencing
Upload/render path → same compose/handlers track (after #18). otto-head assigns when the track frees.
DoD
{token}/0.pngno longer resolves for new cards.Done — tokenized, unguessable media filenames ✅
Each uploaded media object now gets its own fresh ~128-bit
crypto/randfilename component, so the public object URL is no longer derivable from the card token plus a sequential index.Key format
{token}/{rand}.{ext}(was{token}/0.png,{token}/1.png, …){token}/video/{rand}.{ext}(was{token}/video/0.mp4){rand}= 16 bytescrypto/rand, base64url, ~22 chars — same primitive as the card token.Why this closes the #11 media gap pragmatically: the
{rand}is only emitted on the rendered card page (storage_key→PublicURLat render time), which for a password-gated card is served only after the password is verified server-side. So having the card link alone no longer reveals the raw media — page-gating + unguessable media URL. No private bucket, no proxy.Implementation (
internal/server):token.go: factored a sharedrandToken(n)primitive; addednewMediaName()(its own doc-comment ties it to #21).handlers.go:saveImages/saveVideosbuild the key fromnewMediaName()instead of the loop index.Positionstill preserves gallery/order independently of the key. The{token}/prefix is kept, so admin-delete + per-card cleanup still match every object by token prefix.Existing cards (flagged): rows composed before this keep their old guessable
{token}/0.png-style keys —storage_keyis stored per row and drives the render URL, so old cards render unchanged, but their media URLs stay reconstructable. A retroactive re-key (move storage objects + rewrite rows) is out of scope here (live cards are few/test); documented in the README as the optional follow-up if m wants full closure for old cards.Tests (
internal/server/media_key_test.go):TestSaveImagesRandomKey— key matches{token}/{rand}.png, never{token}/0.png, two images get distinct names, object resolves.TestSaveVideosRandomKey—{token}/video/{rand}.mp4, never…/0.mp4.TestNewMediaNameUnguessable— URL-safe, ~128-bit, 1000 draws collision-free.go build/go vet/go test ./...all green. README "Security → Media objects" section rewritten to document the new behaviour + existing-card caveat.Also bundled #22 (copy fix) in the same commit — see that issue.
Commit:
4c08b3816bBranch:
mai/hermes/issue-21-feat-security✅ Merged & deployed — tokenized media filenames live\n\nMerged
4c08b38(merge3d44213), deployed. Verified live: a new card's image is stored at{token}/{rand}.png(128-bit crypto/rand), and guessing{token}/0.png→ 400. So knowing the card token no longer reveals the media URL — page-gating + unguessable media closes the #11 gap for new cards (no private bucket/proxy). Existing cards keep old keys (documented). build/vet/test green. Not closing — m closes.