Repository navigation
Stop serving editor media after a document moves to Trash #408
Copy link
Copy link
Open
Labels
EditorTiptap & ProsemirrorTiptap & ProsemirrorSecuritySecurity, access control, and data exposureSecurity, access control, and data exposurebugSomething isn't workingSomething isn't working
Milestone
Description
Summary
Images and files inside a document stay readable after the document moves to Trash. The media route checks nothing. The objects are also public-read on S3 (Spaces), so the raw object URL skips the route. File names are random UUIDs, so strangers cannot guess them. But anyone who once saw a URL keeps access.
This issue covers Trash and direct object reads. Neither needs to know who asks, so the fix can ship now. Private documents need a maintainer decision first, so they have their own issue.
- Severity: Low
- Area:
apps/hocuspocus.servermedia route and S3 storage - Source: security review of 2026-10-06, finding L3
Where
- Route, public by design:
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).- Objects are written with
acl: 'public-read':apps/hocuspocus.server/src/lib/storage/storage.s3.ts:65(comment at:57-58). - Responses carry
Cache-Control: public, max-age=31536000, immutable:storage.s3.ts:116andapps/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 plainsrc: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
- In
getMedia(documents.controller.ts), loadDocumentMetadatabydocumentIdwithselect: { deletedAt: true }.deletedAtset →fail(c, 404, 'NOT_FOUND', 'Document not found'), the same answeruploadMediagives.- No row → serve as today. A purge already deleted the objects, and older media may sit under row-less ids.
- It is one unique-key lookup per file, so no cache is needed.
- Stop public object reads on S3.
- Remove
acl: 'public-read'fromuploadinstorage.s3.ts:65. Fix the comment at:57-58.getreads through the credentialed client, so the route keeps working. - Run a one-off ACL change on the existing objects under the
production/prefix, for examples3cmd setacl --acl-private --recursive. Record the object count in the PR.
- Remove
- In
duplicateDocument(documents.service.ts), beforecopyDocumentMedia, drop each reference whose prefix document is trashed. Use onefindManyover the distinct prefixes. A dropped reference becomes a missing image in the copy, which is already the answer for a purged source.
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
- Media of a Private document. The media route cannot tell who asks yet. That work is in Serve editor media of a Private document only to its owner #439.
- Revoking copies already in a browser cache.
immutablecopies stay until they expire. - Media inside chat. Chat media lives in the Supabase
mediabucket (see Let non-members read only chat media that a live message uses #432).
Acceptance criteria
- After Trash, the media URL returns
404for everyone. After Restore, it serves again. - The raw Spaces object URL returns
403for new and existing objects. - Media of a public document still loads, with the long
publiccache header. - A duplicate whose snapshot names media of a trashed document copies nothing under that prefix.
Verify
- Local stack with S3 off (
PERSIST_TO_LOCAL_STORAGE=true): upload an image, move the document to Trash, thencurl -ithe media URL and expect404. Restore and expect200. - On staging S3:
curl -ithe raw object URL of a new upload and expect403. - Add a case to
apps/hocuspocus.server/tests/integration/hypermultimedia.test.tsfor the trashed document, then runcd apps/hocuspocus.server && bun test tests/integration/hypermultimedia.test.ts.
Related
- Gating media for Private documents: Serve editor media of a Private document only to its owner #439
- Make a Private document's chat readable and writable only by its owner #396 (Private document chat)
- Check that a document id is one path segment before local media is deleted #426 (local storage path check)
- Let non-members read only chat media that a live message uses #432 (media bucket listing)
Generated by Claude Code
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
EditorTiptap & ProsemirrorTiptap & ProsemirrorSecuritySecurity, access control, and data exposureSecurity, access control, and data exposurebugSomething isn't workingSomething isn't working
Activity
[-][Security] Editor media stays readable after a document goes Private or to Trash[/-][+]Stop serving editor media after a document moves to Trash[/+]fix(hocuspocus): stop serving media of a document in Trash
docs(openapi): match the spec to today's admin, email, media and heal…
docs(repo): record the 9 Oct security and email batch in the changelo…
HMarzban commentedon Oct 9, 2026
Code is live (
6b4185681, deployed inbbdaae66c): the media route answers 404 for a document in Trash, new S3 objects are private, and a duplicate skips trashed media.Left before closing (maintainer): make the existing objects private from a workstation with
s3cmd setacl --acl-private --recursive s3://<bucket>/production/. Thencurl -ithe raw Spaces URL of an old image (expect 403) and the REST media route for it (expect 200).