Skip to content

Serve editor media of a Private document only to its owner #439

Description

@HMarzban

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

  • After a Private flip, a non-owner's request for a known media URL returns 403 or 404.
  • The owner still sees every image in their Private document after a reload.

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

Activity

  1. added theissue type on Oct 6, 2026
  2. added
    bugSomething isn't working
    EditorTiptap & Prosemirror
    SecuritySecurity, access control, and data exposure
    on Oct 6, 2026
  3. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Built but held, not in production. The media gate fails closed when MEDIA_COOKIE_SECRET is unset, so every owner's Private images would 404 if that env line were missed. Maintainer decision owed: set the secret first and ship as built, or approve the gate being off while the secret is unset. The cookie TTL is already 1 hour in the patch.

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