Skip to content

Stop serving editor media after a document moves to Trash #408

Description

@HMarzban

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

  1. 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.
  2. 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.
  3. 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

  • After Trash, the media URL returns 404 for everyone. After Restore, it serves again.
  • The raw Spaces object URL returns 403 for new and existing objects.
  • Media of a public document still loads, with the long public cache 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, 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

Activity

  1. added theissue type on Oct 6, 2026
  2. changed the title [-][Security] Editor media stays readable after a document goes Private or to Trash[/-] [+]Stop serving editor media after a document moves to Trash[/+] on Oct 6, 2026
  3. added
    EditorTiptap & Prosemirror
    SecuritySecurity, access control, and data exposure
    on Oct 6, 2026
  4. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Code is live (6b4185681, deployed in bbdaae66c): 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/. Then curl -i the raw Spaces URL of an old image (expect 403) and the REST media route for it (expect 200).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EditorTiptap & ProsemirrorSecuritySecurity, access control, and data exposurebugSomething isn't working

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions