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.server media 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.
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).
- 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: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
- In
getMedia (documents.controller.ts), load DocumentMetadata by documentId with select: { deletedAt: true }.
deletedAt set → fail(c, 404, 'NOT_FOUND', 'Document not found'), the same answer uploadMedia gives.
- 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' from upload in storage.s3.ts:65. Fix the comment at :57-58. get reads through the credentialed client, so the route keeps working.
- Run a one-off ACL change on the existing objects under the
production/ prefix, for example s3cmd setacl --acl-private --recursive. Record the object count in the PR.
- In
duplicateDocument (documents.service.ts), before copyDocumentMedia, drop each reference whose prefix document is trashed. Use one findMany over 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
Acceptance criteria
Verify
- Local stack with S3 off (
PERSIST_TO_LOCAL_STORAGE=true): upload an image, move the document to Trash, then curl -i the media URL and expect 404. Restore and expect 200.
- On staging S3:
curl -i the raw object URL of a new upload and expect 403.
- Add a case to
apps/hocuspocus.server/tests/integration/hypermultimedia.test.ts for the trashed document, then run cd apps/hocuspocus.server && bun test tests/integration/hypermultimedia.test.ts.
Related
Generated by Claude Code
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-readon 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.
apps/hocuspocus.servermedia route and S3 storageWhere
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).acl: 'public-read':apps/hocuspocus.server/src/lib/storage/storage.s3.ts:65(comment at:57-58).Cache-Control: public, max-age=31536000, immutable: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
getMedia(documents.controller.ts), loadDocumentMetadatabydocumentIdwithselect: { deletedAt: true }.deletedAtset →fail(c, 404, 'NOT_FOUND', 'Document not found'), the same answeruploadMediagives.acl: 'public-read'fromuploadinstorage.s3.ts:65. Fix the comment at:57-58.getreads through the credentialed client, so the route keeps working.production/prefix, for examples3cmd setacl --acl-private --recursive. Record the object count in the PR.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 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
404for everyone. After Restore, it serves again.403for new and existing objects.publiccache header.Verify
PERSIST_TO_LOCAL_STORAGE=true): upload an image, move the document to Trash, thencurl -ithe media URL and expect404. Restore and expect200.curl -ithe raw object URL of a new upload and expect403.apps/hocuspocus.server/tests/integration/hypermultimedia.test.tsfor the trashed document, then runcd apps/hocuspocus.server && bun test tests/integration/hypermultimedia.test.ts.Related
Generated by Claude Code