Summary
Decision needed first: the maintainer must choose how a media request proves who asks. Nobody should write code before that choice. The smallest option found is in the fix plan below.
Editor media of a Private document stays readable to anyone who once saw its URL. The media route cannot apply resolvePrivateAccess, because an image request has no identity. The editor loads media with a plain src attribute, so the request carries no token. The Supabase session cookie is host-only on docs.plus and never reaches prodback.docs.plus.
Where
- Route, public by design:
apps/hocuspocus.server/src/api/routers/hypermultimedia.router.ts:30-35.
getMedia calls the store with no metadata, privacy or Trash check: apps/hocuspocus.server/src/api/controllers/documents.controller.ts:410-421 and apps/hocuspocus.server/src/api/services/media.service.ts:14-15. Upload does check (documents.controller.ts:431-451).
- Responses carry
Cache-Control: public, max-age=31536000, immutable: apps/hocuspocus.server/src/lib/storage/storage.s3.ts:116 and apps/hocuspocus.server/src/lib/storage/storage.local.ts:80.
- The editor stores
${NEXT_PUBLIC_RESTAPI_URL}/plugins/hypermultimedia/<documentId>/<file> and renders it as a plain src: apps/webapp/src/components/TipTap/mediaPopovers/uploadMediaFile.ts:195.
- Duplicate copies every object the snapshot names, under any prefix:
apps/hocuspocus.server/src/api/services/documents.service.ts:851-861, media.service.ts:39-68.
Fix plan
Decision (maintainer)
Pick the credential before coding. The smallest option found:
- The webapp calls a new
POST /api/plugins/hypermultimedia/:documentId/session with its Authorization header when it opens a Private document.
- REST runs
resolvePrivateAccess and sets a short-lived, signed, HttpOnly, Secure, SameSite=Lax cookie with Path=/api/plugins/hypermultimedia/<documentId>. The two hosts are same-site, so image requests then carry it. CORS already allows credentials.
getMedia serves a Private document's media only with a valid cookie for that document, and sends Cache-Control: private, no-cache.
- No change to the published
@docs.plus/extension-hypermultimedia node views.
Duplicate
#408 step 3 drops references whose prefix document is trashed. Extend that filter here. Also drop references whose prefix document is Private and not owned by the requester (resolvePrivateAccess).
Layer: the gate belongs in the REST media controller (getMedia), next to the upload gate. Do not put access logic in lib/storage/*; those adapters only move bytes. Keep the media-read rate-limit bucket as it is.
Out of scope
Acceptance criteria
Verify
- Local stack: as the owner, flip a document with an image to Private, then reload. Every image must load.
- Request the same media URL as another account, or with
curl -i and no cookie. Expect 403 or 404.
Related
Generated by Claude Code
Summary
Decision needed first: the maintainer must choose how a media request proves who asks. Nobody should write code before that choice. The smallest option found is in the fix plan below.
Editor media of a Private document stays readable to anyone who once saw its URL. The media route cannot apply
resolvePrivateAccess, because an image request has no identity. The editor loads media with a plainsrcattribute, so the request carries no token. The Supabase session cookie is host-only ondocs.plusand never reachesprodback.docs.plus.apps/hocuspocus.servermedia route, and the webapp editor that loads mediaWhere
apps/hocuspocus.server/src/api/routers/hypermultimedia.router.ts:30-35.getMediacalls the store with no metadata, privacy or Trash check:apps/hocuspocus.server/src/api/controllers/documents.controller.ts:410-421andapps/hocuspocus.server/src/api/services/media.service.ts:14-15. Upload does check (documents.controller.ts:431-451).Cache-Control: public, max-age=31536000, immutable:apps/hocuspocus.server/src/lib/storage/storage.s3.ts:116andapps/hocuspocus.server/src/lib/storage/storage.local.ts:80.${NEXT_PUBLIC_RESTAPI_URL}/plugins/hypermultimedia/<documentId>/<file>and renders it as a plainsrc:apps/webapp/src/components/TipTap/mediaPopovers/uploadMediaFile.ts:195.apps/hocuspocus.server/src/api/services/documents.service.ts:851-861,media.service.ts:39-68.Fix plan
Decision (maintainer)
Pick the credential before coding. The smallest option found:
POST /api/plugins/hypermultimedia/:documentId/sessionwith itsAuthorizationheader when it opens a Private document.resolvePrivateAccessand sets a short-lived, signed,HttpOnly,Secure,SameSite=Laxcookie withPath=/api/plugins/hypermultimedia/<documentId>. The two hosts are same-site, so image requests then carry it. CORS already allows credentials.getMediaserves a Private document's media only with a valid cookie for that document, and sendsCache-Control: private, no-cache.@docs.plus/extension-hypermultimedianode views.Duplicate
#408 step 3 drops references whose prefix document is trashed. Extend that filter here. Also drop references whose prefix document is Private and not owned by the requester (
resolvePrivateAccess).Layer: the gate belongs in the REST media controller (
getMedia), next to the upload gate. Do not put access logic inlib/storage/*; those adapters only move bytes. Keep the media-read rate-limit bucket as it is.Out of scope
immutablecopies stay until they expire.mediabucket (see Let non-members read only chat media that a live message uses #432).Acceptance criteria
403or404.Verify
curl -iand no cookie. Expect403or404.Related
Generated by Claude Code