Skip to content

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

Closed

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).

  5. HMarzban commented on Oct 21, 2026

    @HMarzban
    CollaboratorAuthor

    Done on production, 2026-10-21.

    • Safety read first: 0 of 938 live document heads link straight to Spaces, so no document breaks.
    • The bucket ACL is now private, so file listing is restricted. All 45 objects under production/ are now private.
    • Checks: the raw Spaces URL of an old image answers 403, the bucket listing answers 403, and the CDN URL answers 403 once its edge cache expired (TTL set to 1 min). The docs.plus media route still answers 200 for the same images.
    • The code half (Trash answers 404, new objects private, duplicate skips trashed media) shipped on 2026-10-09.
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

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions