feat/security: tokenized, unguessable media filenames (closes #11 media leak) #21

Closed
opened 2026-06-17 12:28:31 +00:00 by mAi · 2 comments
Collaborator

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}.mp4 where {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_key already 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/server upload handlers + the store save).
  • New uploads get random keys. Existing cards keep their old (guessable) keys — note this; a retroactive re-key would need moving storage objects + updating rows (out of scope unless m wants it; flag it). m's live cards are few/test.
  • Keep within the existing {token}/… prefix so admin-delete + per-card cleanup still work by token prefix.

Constraints

  • crypto/rand for the per-file token; collision-safe. No schema change needed (storage_key is already free-form text). build/vet/test green; e2e (upload → media key is random, not 0.png; card renders + media loads; guessing {token}/0.png 404s for a new card).

Sequencing

Upload/render path → same compose/handlers track (after #18). otto-head assigns when the track frees.

DoD

  • New cards' media stored under unguessable per-file names; {token}/0.png no longer resolves for new cards.
  • Render + admin delete + cleanup still work. Existing-card behaviour documented. Commit references this issue + closes the #11 media gap pragmatically.
## 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}.mp4` where `{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_key` already 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/server` upload handlers + the `store` save). - New uploads get random keys. **Existing cards** keep their old (guessable) keys — note this; a retroactive re-key would need moving storage objects + updating rows (out of scope unless m wants it; flag it). m's live cards are few/test. - Keep within the existing `{token}/…` prefix so admin-delete + per-card cleanup still work by token prefix. ## Constraints - crypto/rand for the per-file token; collision-safe. No schema change needed (storage_key is already free-form text). build/vet/test green; e2e (upload → media key is random, not 0.png; card renders + media loads; guessing `{token}/0.png` 404s for a new card). ## Sequencing Upload/render path → same compose/handlers track (after #18). otto-head assigns when the track frees. ## DoD - New cards' media stored under unguessable per-file names; `{token}/0.png` no longer resolves for new cards. - Render + admin delete + cleanup still work. Existing-card behaviour documented. Commit references this issue + closes the #11 media gap pragmatically.
mAi self-assigned this 2026-06-17 12:42:53 +00:00
Author
Collaborator

Done — tokenized, unguessable media filenames

Each uploaded media object now gets its own fresh ~128-bit crypto/rand filename component, so the public object URL is no longer derivable from the card token plus a sequential index.

Key format

  • Images: {token}/{rand}.{ext} (was {token}/0.png, {token}/1.png, …)
  • Videos: {token}/video/{rand}.{ext} (was {token}/video/0.mp4)
  • {rand} = 16 bytes crypto/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_keyPublicURL at 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 shared randToken(n) primitive; added newMediaName() (its own doc-comment ties it to #21).
  • handlers.go: saveImages / saveVideos build the key from newMediaName() instead of the loop index. Position still 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_key is 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: 4c08b3816b
Branch: mai/hermes/issue-21-feat-security

## Done — tokenized, unguessable media filenames ✅ Each uploaded media object now gets its **own fresh ~128-bit `crypto/rand` filename component**, so the public object URL is no longer derivable from the card token plus a sequential index. **Key format** - Images: `{token}/{rand}.{ext}` (was `{token}/0.png`, `{token}/1.png`, …) - Videos: `{token}/video/{rand}.{ext}` (was `{token}/video/0.mp4`) - `{rand}` = 16 bytes `crypto/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` → `PublicURL` at 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 shared `randToken(n)` primitive; added `newMediaName()` (its own doc-comment ties it to #21). - `handlers.go`: `saveImages` / `saveVideos` build the key from `newMediaName()` instead of the loop index. `Position` still 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_key` is 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: https://mgit.msbls.de/m/postcards/commit/4c08b3816b83dde30cbea233b62c631730aa189e Branch: `mai/hermes/issue-21-feat-security`
Author
Collaborator

Merged & deployed — tokenized media filenames live\n\nMerged 4c08b38 (merge 3d44213), 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.

## ✅ Merged & deployed — tokenized media filenames live\n\nMerged `4c08b38` (merge `3d44213`), 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.
mAi closed this issue 2026-06-23 15:48:03 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/postcards#21
No description provided.